mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-01 11:53:36 -04:00
refactor: update SpaceItem structure to include space-level items, modify related database entities and queries, and enhance item creation logic
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -168,6 +168,7 @@
|
||||
"@sd/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -1448,9 +1449,9 @@
|
||||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.10.8", "", { "dependencies": { "@tanstack/virtual-core": "3.10.8" }, "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0" } }, "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA=="],
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.10.8", "", {}, "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA=="],
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
|
||||
|
||||
"@taplo/cli": ["@taplo/cli@0.7.0", "", { "bin": { "taplo": "dist/cli.js" } }, "sha512-Ck3zFhQhIhi02Hl6T4ZmJsXdnJE+wXcJz5f8klxd4keRYgenMnip3JDPMGDRLbnC/2iGd8P0sBIQqI3KxfVjBg=="],
|
||||
|
||||
@@ -4624,6 +4625,8 @@
|
||||
|
||||
"@expo/xcpretty/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"@headlessui/react/@tanstack/react-virtual": ["@tanstack/react-virtual@3.10.8", "", { "dependencies": { "@tanstack/virtual-core": "3.10.8" }, "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0" } }, "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA=="],
|
||||
|
||||
"@ianvs/prettier-plugin-sort-imports/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "0.2.0", "emoji-regex": "9.2.2", "strip-ansi": "7.1.0" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
@@ -8122,6 +8125,8 @@
|
||||
|
||||
"@expo/xcpretty/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@headlessui/react/@tanstack/react-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.10.8", "", {}, "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||
|
||||
@@ -160,19 +160,22 @@ pub enum GroupType {
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// An item within a group
|
||||
/// An item within a space (can be space-level or within a group)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct SpaceItem {
|
||||
/// Unique identifier
|
||||
pub id: Uuid,
|
||||
|
||||
/// Group this item belongs to
|
||||
pub group_id: Uuid,
|
||||
/// Space this item belongs to
|
||||
pub space_id: Uuid,
|
||||
|
||||
/// Group this item belongs to (None = space-level item)
|
||||
pub group_id: Option<Uuid>,
|
||||
|
||||
/// Type and data of this item
|
||||
pub item_type: ItemType,
|
||||
|
||||
/// Sort order within group
|
||||
/// Sort order within space or group
|
||||
pub order: i32,
|
||||
|
||||
/// Timestamp
|
||||
@@ -180,11 +183,24 @@ pub struct SpaceItem {
|
||||
}
|
||||
|
||||
impl SpaceItem {
|
||||
/// Create a new item
|
||||
pub fn new(group_id: Uuid, item_type: ItemType) -> Self {
|
||||
/// Create a new item within a group
|
||||
pub fn new(space_id: Uuid, group_id: Uuid, item_type: ItemType) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
group_id,
|
||||
space_id,
|
||||
group_id: Some(group_id),
|
||||
item_type,
|
||||
order: 0,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new space-level item (not in any group)
|
||||
pub fn new_space_level(space_id: Uuid, item_type: ItemType) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
space_id,
|
||||
group_id: None,
|
||||
item_type,
|
||||
order: 0,
|
||||
created_at: Utc::now(),
|
||||
@@ -192,28 +208,33 @@ impl SpaceItem {
|
||||
}
|
||||
|
||||
/// Create an Overview item
|
||||
pub fn create_overview(group_id: Uuid) -> Self {
|
||||
Self::new(group_id, ItemType::Overview)
|
||||
pub fn create_overview(space_id: Uuid, group_id: Uuid) -> Self {
|
||||
Self::new(space_id, group_id, ItemType::Overview)
|
||||
}
|
||||
|
||||
/// Create a Recents item
|
||||
pub fn create_recents(group_id: Uuid) -> Self {
|
||||
Self::new(group_id, ItemType::Recents)
|
||||
pub fn create_recents(space_id: Uuid, group_id: Uuid) -> Self {
|
||||
Self::new(space_id, group_id, ItemType::Recents)
|
||||
}
|
||||
|
||||
/// Create a Favorites item
|
||||
pub fn create_favorites(group_id: Uuid) -> Self {
|
||||
Self::new(group_id, ItemType::Favorites)
|
||||
pub fn create_favorites(space_id: Uuid, group_id: Uuid) -> Self {
|
||||
Self::new(space_id, group_id, ItemType::Favorites)
|
||||
}
|
||||
|
||||
/// Create a Location item
|
||||
pub fn create_location(group_id: Uuid, location_id: Uuid) -> Self {
|
||||
Self::new(group_id, ItemType::Location { location_id })
|
||||
pub fn create_location(space_id: Uuid, group_id: Uuid, location_id: Uuid) -> Self {
|
||||
Self::new(space_id, group_id, ItemType::Location { location_id })
|
||||
}
|
||||
|
||||
/// Create a Path item (arbitrary SdPath)
|
||||
pub fn create_path(group_id: Uuid, sd_path: SdPath) -> Self {
|
||||
Self::new(group_id, ItemType::Path { sd_path })
|
||||
pub fn create_path(space_id: Uuid, group_id: Uuid, sd_path: SdPath) -> Self {
|
||||
Self::new(space_id, group_id, ItemType::Path { sd_path })
|
||||
}
|
||||
|
||||
/// Create a space-level Path item (pinned shortcut)
|
||||
pub fn create_space_level_path(space_id: Uuid, sd_path: SdPath) -> Self {
|
||||
Self::new_space_level(space_id, ItemType::Path { sd_path })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +279,9 @@ pub struct SpaceLayout {
|
||||
/// The space
|
||||
pub space: Space,
|
||||
|
||||
/// Space-level items (pinned shortcuts, no group)
|
||||
pub space_items: Vec<SpaceItem>,
|
||||
|
||||
/// Groups with their items
|
||||
pub groups: Vec<SpaceGroupWithItems>,
|
||||
}
|
||||
@@ -331,15 +355,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_item_creation() {
|
||||
let space_id = Uuid::new_v4();
|
||||
let group_id = Uuid::new_v4();
|
||||
|
||||
let overview = SpaceItem::create_overview(group_id);
|
||||
let overview = SpaceItem::create_overview(space_id, group_id);
|
||||
assert_eq!(overview.item_type, ItemType::Overview);
|
||||
assert_eq!(overview.group_id, Some(group_id));
|
||||
|
||||
let recents = SpaceItem::create_recents(group_id);
|
||||
let recents = SpaceItem::create_recents(space_id, group_id);
|
||||
assert_eq!(recents.item_type, ItemType::Recents);
|
||||
|
||||
let favorites = SpaceItem::create_favorites(group_id);
|
||||
let favorites = SpaceItem::create_favorites(space_id, group_id);
|
||||
assert_eq!(favorites.item_type, ItemType::Favorites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_space_level_item() {
|
||||
let space_id = Uuid::new_v4();
|
||||
let sd_path = crate::domain::SdPath::Physical {
|
||||
device_slug: "macbook".to_string(),
|
||||
path: "/Users/me/Documents".into(),
|
||||
};
|
||||
|
||||
let item = SpaceItem::create_space_level_path(space_id, sd_path);
|
||||
assert_eq!(item.space_id, space_id);
|
||||
assert_eq!(item.group_id, None);
|
||||
assert!(matches!(item.item_type, ItemType::Path { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,21 @@ pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub uuid: Uuid,
|
||||
pub group_id: i32,
|
||||
pub item_type: String, // JSON-serialized ItemType enum
|
||||
pub space_id: i32,
|
||||
pub group_id: Option<i32>, // Nullable - None = space-level item
|
||||
pub item_type: String, // JSON-serialized ItemType enum
|
||||
pub order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::space::Entity",
|
||||
from = "Column::SpaceId",
|
||||
to = "super::space::Column::Id"
|
||||
)]
|
||||
Space,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::space_group::Entity",
|
||||
from = "Column::GroupId",
|
||||
@@ -26,6 +33,12 @@ pub enum Relation {
|
||||
SpaceGroup,
|
||||
}
|
||||
|
||||
impl Related<super::space::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Space.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::space_group::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::SpaceGroup.def()
|
||||
@@ -49,18 +62,18 @@ impl Syncable for Model {
|
||||
}
|
||||
|
||||
fn exclude_fields() -> Option<&'static [&'static str]> {
|
||||
Some(&["id", "group_id"])
|
||||
Some(&["id", "space_id", "group_id"])
|
||||
}
|
||||
|
||||
fn sync_depends_on() -> &'static [&'static str] {
|
||||
&["space_group"]
|
||||
&["space", "space_group"]
|
||||
}
|
||||
|
||||
fn foreign_key_mappings() -> Vec<crate::infra::sync::FKMapping> {
|
||||
vec![crate::infra::sync::FKMapping::new(
|
||||
"group_id",
|
||||
"space_groups",
|
||||
)]
|
||||
vec![
|
||||
crate::infra::sync::FKMapping::new("space_id", "spaces"),
|
||||
crate::infra::sync::FKMapping::new("group_id", "space_groups"),
|
||||
]
|
||||
}
|
||||
|
||||
async fn query_for_sync(
|
||||
|
||||
@@ -130,10 +130,15 @@ impl MigrationTrait for Migration {
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(SpaceItems::GroupId)
|
||||
ColumnDef::new(SpaceItems::SpaceId)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(SpaceItems::GroupId)
|
||||
.integer()
|
||||
.null(), // Nullable - None = space-level item
|
||||
)
|
||||
.col(ColumnDef::new(SpaceItems::ItemType).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(SpaceItems::Order)
|
||||
@@ -147,6 +152,13 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_space_item_space")
|
||||
.from(SpaceItems::Table, SpaceItems::SpaceId)
|
||||
.to(Spaces::Table, Spaces::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_space_item_group")
|
||||
@@ -189,6 +201,16 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_space_items_space_id")
|
||||
.table(SpaceItems::Table)
|
||||
.col(SpaceItems::SpaceId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
@@ -261,6 +283,7 @@ enum SpaceItems {
|
||||
Table,
|
||||
Id,
|
||||
Uuid,
|
||||
SpaceId,
|
||||
GroupId,
|
||||
ItemType,
|
||||
Order,
|
||||
|
||||
@@ -1012,7 +1012,8 @@ impl LibraryManager {
|
||||
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),
|
||||
space_id: Set(space_result.id),
|
||||
group_id: Set(Some(group_result.id)),
|
||||
item_type: Set(item_type_json),
|
||||
order: Set(order),
|
||||
created_at: Set(now.into()),
|
||||
|
||||
593
core/src/ops/files/query/media_listing.rs
Normal file
593
core/src/ops/files/query/media_listing.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
//! Query to list media content (images/videos) for gallery views
|
||||
//!
|
||||
//! This query is optimized for gallery/camera roll UI patterns.
|
||||
//! It returns media files (images and videos) from a directory and optionally
|
||||
//! includes all descendants, making any directory browsable as a media gallery.
|
||||
|
||||
use crate::infra::query::{QueryError, QueryResult};
|
||||
use crate::{
|
||||
context::CoreContext,
|
||||
domain::{
|
||||
addressing::SdPath,
|
||||
content_identity::ContentIdentity,
|
||||
file::File,
|
||||
ContentKind,
|
||||
},
|
||||
infra::db::entities::{
|
||||
content_identity, directory_paths, entry, sidecar,
|
||||
},
|
||||
infra::query::LibraryQuery,
|
||||
};
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Input for media listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct MediaListingInput {
|
||||
/// The directory path to list media for
|
||||
pub path: SdPath,
|
||||
/// Whether to include media from descendant directories (default: false)
|
||||
pub include_descendants: Option<bool>,
|
||||
/// Which media types to include (default: both Image and Video)
|
||||
pub media_types: Option<Vec<ContentKind>>,
|
||||
/// Optional limit on number of results (default: 1000)
|
||||
pub limit: Option<u32>,
|
||||
/// Sort order for results
|
||||
pub sort_by: MediaSortBy,
|
||||
}
|
||||
|
||||
/// Sort options for media listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaSortBy {
|
||||
/// Sort by modification date (newest first)
|
||||
Modified,
|
||||
/// Sort by creation date (newest first)
|
||||
Created,
|
||||
/// Sort by name (alphabetical)
|
||||
Name,
|
||||
/// Sort by size (largest first)
|
||||
Size,
|
||||
}
|
||||
|
||||
/// Output containing media files
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct MediaListingOutput {
|
||||
/// Media files (images/videos)
|
||||
pub files: Vec<File>,
|
||||
/// Total count of media files found
|
||||
pub total_count: u32,
|
||||
/// Whether there are more results than returned
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Query to list media content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct MediaListingQuery {
|
||||
pub input: MediaListingInput,
|
||||
}
|
||||
|
||||
impl MediaListingQuery {
|
||||
pub fn new(path: SdPath) -> Self {
|
||||
Self {
|
||||
input: MediaListingInput {
|
||||
path,
|
||||
include_descendants: Some(false),
|
||||
media_types: Some(vec![ContentKind::Image, ContentKind::Video]),
|
||||
limit: Some(1000),
|
||||
sort_by: MediaSortBy::Modified,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_options(
|
||||
path: SdPath,
|
||||
include_descendants: Option<bool>,
|
||||
media_types: Option<Vec<ContentKind>>,
|
||||
limit: Option<u32>,
|
||||
sort_by: MediaSortBy,
|
||||
) -> Self {
|
||||
Self {
|
||||
input: MediaListingInput {
|
||||
path,
|
||||
include_descendants,
|
||||
media_types,
|
||||
limit,
|
||||
sort_by,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryQuery for MediaListingQuery {
|
||||
type Input = MediaListingInput;
|
||||
type Output = MediaListingOutput;
|
||||
|
||||
fn from_input(input: Self::Input) -> QueryResult<Self> {
|
||||
tracing::info!(
|
||||
"MediaListingQuery::from_input called with input: {:?}",
|
||||
input
|
||||
);
|
||||
Ok(Self { input })
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
self,
|
||||
context: Arc<CoreContext>,
|
||||
session: crate::infra::api::SessionContext,
|
||||
) -> QueryResult<Self::Output> {
|
||||
tracing::info!(
|
||||
"MediaListingQuery::execute called with path: {:?}",
|
||||
self.input.path
|
||||
);
|
||||
|
||||
let library_id = session
|
||||
.current_library_id
|
||||
.ok_or_else(|| QueryError::Internal("No library in session".to_string()))?;
|
||||
tracing::info!("Library ID: {}", library_id);
|
||||
|
||||
let library = context
|
||||
.libraries()
|
||||
.await
|
||||
.get_library(library_id)
|
||||
.await
|
||||
.ok_or_else(|| QueryError::Internal("Library not found".to_string()))?;
|
||||
|
||||
let db = library.db();
|
||||
|
||||
// First, find the parent directory entry
|
||||
let parent_entry = self.find_parent_directory(db.conn()).await?;
|
||||
let parent_id = parent_entry.id;
|
||||
|
||||
// Get media types to filter (default to Image and Video)
|
||||
let media_types = self
|
||||
.input
|
||||
.media_types
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec![ContentKind::Image, ContentKind::Video]);
|
||||
|
||||
// Convert ContentKind enum to database IDs
|
||||
let media_type_ids: Vec<i32> = media_types.iter().map(|k| *k as i32).collect();
|
||||
let media_type_ids_str = media_type_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
// Build efficient SQL query
|
||||
let mut sql_query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
e.id as entry_id,
|
||||
e.uuid as entry_uuid,
|
||||
e.name as entry_name,
|
||||
e.kind as entry_kind,
|
||||
e.extension as entry_extension,
|
||||
e.size as entry_size,
|
||||
e.created_at as entry_created_at,
|
||||
e.modified_at as entry_modified_at,
|
||||
e.accessed_at as entry_accessed_at,
|
||||
e.inode as entry_inode,
|
||||
e.parent_id as entry_parent_id,
|
||||
ci.id as content_identity_id,
|
||||
ci.uuid as content_identity_uuid,
|
||||
ci.content_hash as content_hash,
|
||||
ci.integrity_hash as integrity_hash,
|
||||
ci.mime_type_id as mime_type_id,
|
||||
ci.text_content as text_content,
|
||||
ci.total_size as total_size,
|
||||
ci.entry_count as entry_count,
|
||||
ci.first_seen_at as first_seen_at,
|
||||
ci.last_verified_at as last_verified_at,
|
||||
ck.id as content_kind_id,
|
||||
ck.name as content_kind_name,
|
||||
dp.path as directory_path
|
||||
FROM entries e
|
||||
INNER JOIN content_identities ci ON e.content_id = ci.id
|
||||
LEFT JOIN content_kinds ck ON ci.kind_id = ck.id
|
||||
LEFT JOIN directory_paths dp ON e.parent_id = dp.entry_id
|
||||
WHERE ci.kind_id IN ({})
|
||||
"#,
|
||||
media_type_ids_str
|
||||
);
|
||||
|
||||
// Apply directory scope filter
|
||||
if self.input.include_descendants.unwrap_or(false) {
|
||||
// Include all descendants: match entries where directory path starts with parent path
|
||||
sql_query.push_str(" AND (e.parent_id = ?1 OR dp.path LIKE ?2)");
|
||||
} else {
|
||||
// Only direct children
|
||||
sql_query.push_str(" AND e.parent_id = ?1");
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
match self.input.sort_by {
|
||||
MediaSortBy::Modified => sql_query.push_str(" ORDER BY e.modified_at DESC"),
|
||||
MediaSortBy::Created => sql_query.push_str(" ORDER BY e.created_at DESC"),
|
||||
MediaSortBy::Name => sql_query.push_str(" ORDER BY e.name ASC"),
|
||||
MediaSortBy::Size => sql_query.push_str(" ORDER BY e.size DESC"),
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if let Some(limit) = self.input.limit {
|
||||
sql_query.push_str(&format!(" LIMIT {}", limit));
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
let parent_path_pattern = if self.input.include_descendants.unwrap_or(false) {
|
||||
// Get the directory path for the parent to construct LIKE pattern
|
||||
let parent_path = self.get_parent_directory_path(db.conn(), parent_id).await?;
|
||||
Some(format!("{}/%", parent_path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Execute the query
|
||||
let rows = if let Some(path_pattern) = parent_path_pattern {
|
||||
db.conn()
|
||||
.query_all(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Sqlite,
|
||||
&sql_query,
|
||||
[parent_id.into(), path_pattern.into()],
|
||||
))
|
||||
.await?
|
||||
} else {
|
||||
db.conn()
|
||||
.query_all(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Sqlite,
|
||||
&sql_query,
|
||||
[parent_id.into()],
|
||||
))
|
||||
.await?
|
||||
};
|
||||
|
||||
tracing::debug!("Query executed, found {} media files", rows.len());
|
||||
|
||||
if rows.is_empty() {
|
||||
tracing::debug!("No media files found");
|
||||
return Ok(MediaListingOutput {
|
||||
files: Vec::new(),
|
||||
total_count: 0,
|
||||
has_more: false,
|
||||
});
|
||||
}
|
||||
|
||||
let total_count = rows.len() as u32;
|
||||
|
||||
// Collect all content UUIDs for batch sidecar query
|
||||
let content_uuids: Vec<Uuid> = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get::<Option<Uuid>>("", "content_identity_uuid").ok().flatten())
|
||||
.collect();
|
||||
|
||||
// Batch fetch all sidecars for these content UUIDs
|
||||
let all_sidecars = if !content_uuids.is_empty() {
|
||||
sidecar::Entity::find()
|
||||
.filter(sidecar::Column::ContentUuid.is_in(content_uuids.clone()))
|
||||
.all(db.conn())
|
||||
.await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Group sidecars by content_uuid for fast lookup
|
||||
let mut sidecars_by_content: HashMap<Uuid, Vec<crate::domain::file::Sidecar>> = HashMap::new();
|
||||
for s in all_sidecars {
|
||||
sidecars_by_content
|
||||
.entry(s.content_uuid)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(crate::domain::file::Sidecar {
|
||||
id: s.id,
|
||||
content_uuid: s.content_uuid,
|
||||
kind: s.kind,
|
||||
variant: s.variant,
|
||||
format: s.format,
|
||||
status: s.status,
|
||||
size: s.size,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to File objects
|
||||
let mut files = Vec::new();
|
||||
for row in rows {
|
||||
// Extract data from SQL row
|
||||
let entry_id: i32 = row.try_get("", "entry_id").unwrap_or(0);
|
||||
let entry_uuid: Option<Uuid> = row.try_get("", "entry_uuid").ok();
|
||||
let entry_name: String = row.try_get("", "entry_name").unwrap_or_default();
|
||||
let entry_kind: i32 = row.try_get("", "entry_kind").unwrap_or(0);
|
||||
let entry_extension: Option<String> = row.try_get("", "entry_extension").ok();
|
||||
let entry_size: i64 = row.try_get("", "entry_size").unwrap_or(0);
|
||||
let entry_created_at: chrono::DateTime<chrono::Utc> = row
|
||||
.try_get("", "entry_created_at")
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
let entry_modified_at: chrono::DateTime<chrono::Utc> = row
|
||||
.try_get("", "entry_modified_at")
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
let entry_accessed_at: Option<chrono::DateTime<chrono::Utc>> =
|
||||
row.try_get("", "entry_accessed_at").ok();
|
||||
|
||||
// Content identity data
|
||||
let content_identity_uuid: Option<Uuid> = row.try_get("", "content_identity_uuid").ok();
|
||||
let content_hash: Option<String> = row.try_get("", "content_hash").ok();
|
||||
let integrity_hash: Option<String> = row.try_get("", "integrity_hash").ok();
|
||||
let mime_type_id: Option<i32> = row.try_get("", "mime_type_id").ok();
|
||||
let text_content: Option<String> = row.try_get("", "text_content").ok();
|
||||
let total_size: Option<i64> = row.try_get("", "total_size").ok();
|
||||
let entry_count: Option<i32> = row.try_get("", "entry_count").ok();
|
||||
let first_seen_at: Option<chrono::DateTime<chrono::Utc>> =
|
||||
row.try_get("", "first_seen_at").ok();
|
||||
let last_verified_at: Option<chrono::DateTime<chrono::Utc>> =
|
||||
row.try_get("", "last_verified_at").ok();
|
||||
|
||||
// Content kind data
|
||||
let content_kind_name: Option<String> = row.try_get("", "content_kind_name").ok();
|
||||
|
||||
// Directory path
|
||||
let directory_path: Option<String> = row.try_get("", "directory_path").ok();
|
||||
|
||||
// Build full path with extension
|
||||
let full_name = if let Some(ext) = &entry_extension {
|
||||
format!("{}.{}", entry_name, ext)
|
||||
} else {
|
||||
entry_name.clone()
|
||||
};
|
||||
|
||||
// Construct full file path
|
||||
let entry_sd_path = if let Some(dir_path) = directory_path {
|
||||
let full_path = if dir_path.ends_with('/') {
|
||||
format!("{}{}", dir_path, full_name)
|
||||
} else {
|
||||
format!("{}/{}", dir_path, full_name)
|
||||
};
|
||||
|
||||
match &self.input.path {
|
||||
SdPath::Physical { device_slug, .. } => SdPath::Physical {
|
||||
device_slug: device_slug.clone(),
|
||||
path: full_path.into(),
|
||||
},
|
||||
SdPath::Cloud {
|
||||
service,
|
||||
identifier,
|
||||
..
|
||||
} => SdPath::Cloud {
|
||||
service: *service,
|
||||
identifier: identifier.clone(),
|
||||
path: full_path,
|
||||
},
|
||||
SdPath::Content { content_id } => SdPath::Content {
|
||||
content_id: *content_id,
|
||||
},
|
||||
SdPath::Sidecar { .. } => {
|
||||
return Err(QueryError::Internal(
|
||||
"Sidecar paths not supported for media listing".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to constructing path from parent
|
||||
match &self.input.path {
|
||||
SdPath::Physical { device_slug, path } => SdPath::Physical {
|
||||
device_slug: device_slug.clone(),
|
||||
path: path.join(&full_name).into(),
|
||||
},
|
||||
SdPath::Cloud {
|
||||
service,
|
||||
identifier,
|
||||
path,
|
||||
} => SdPath::Cloud {
|
||||
service: *service,
|
||||
identifier: identifier.clone(),
|
||||
path: format!("{}/{}", path, full_name),
|
||||
},
|
||||
SdPath::Content { content_id } => SdPath::Content {
|
||||
content_id: *content_id,
|
||||
},
|
||||
SdPath::Sidecar { .. } => {
|
||||
return Err(QueryError::Internal(
|
||||
"Sidecar paths not supported for media listing".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create entity model for conversion
|
||||
let entity_model = entry::Model {
|
||||
id: entry_id,
|
||||
uuid: entry_uuid,
|
||||
name: entry_name,
|
||||
kind: entry_kind,
|
||||
extension: entry_extension,
|
||||
metadata_id: None,
|
||||
content_id: None,
|
||||
size: entry_size,
|
||||
aggregate_size: 0,
|
||||
child_count: 0,
|
||||
file_count: 0,
|
||||
created_at: entry_created_at,
|
||||
modified_at: entry_modified_at,
|
||||
accessed_at: entry_accessed_at,
|
||||
indexed_at: None,
|
||||
permissions: None,
|
||||
inode: None,
|
||||
parent_id: None,
|
||||
};
|
||||
|
||||
// Convert to File using from_entity_model
|
||||
let mut file = File::from_entity_model(entity_model, entry_sd_path);
|
||||
|
||||
// Add content identity if available
|
||||
if let (
|
||||
Some(ci_uuid),
|
||||
Some(ci_hash),
|
||||
Some(ci_first_seen),
|
||||
Some(ci_last_verified),
|
||||
) = (
|
||||
content_identity_uuid,
|
||||
content_hash,
|
||||
first_seen_at,
|
||||
last_verified_at,
|
||||
) {
|
||||
// Convert content_kind name to ContentKind enum
|
||||
let kind = content_kind_name
|
||||
.as_ref()
|
||||
.map(|name| ContentKind::from(name.as_str()))
|
||||
.unwrap_or(ContentKind::Unknown);
|
||||
|
||||
file.content_identity = Some(ContentIdentity {
|
||||
uuid: ci_uuid,
|
||||
kind,
|
||||
content_hash: ci_hash,
|
||||
integrity_hash,
|
||||
mime_type_id,
|
||||
text_content,
|
||||
total_size: total_size.unwrap_or(0),
|
||||
entry_count: entry_count.unwrap_or(0),
|
||||
first_seen_at: ci_first_seen,
|
||||
last_verified_at: ci_last_verified,
|
||||
});
|
||||
file.content_kind = kind;
|
||||
|
||||
// Add sidecars from batch lookup
|
||||
if let Some(sidecars) = sidecars_by_content.get(&ci_uuid) {
|
||||
file.sidecars = sidecars.clone();
|
||||
}
|
||||
}
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
let has_more = if let Some(limit) = self.input.limit {
|
||||
total_count > limit
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(MediaListingOutput {
|
||||
files,
|
||||
total_count,
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaListingQuery {
|
||||
/// Find the parent directory entry for the given SdPath
|
||||
async fn find_parent_directory(&self, db: &DatabaseConnection) -> QueryResult<entry::Model> {
|
||||
tracing::debug!(
|
||||
"find_parent_directory called with path: {:?}",
|
||||
self.input.path
|
||||
);
|
||||
|
||||
match &self.input.path {
|
||||
SdPath::Physical { device_slug, path } => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
tracing::debug!("Looking for directory path: '{}'", path_str);
|
||||
|
||||
let directory_path = directory_paths::Entity::find()
|
||||
.filter(directory_paths::Column::Path.eq(&path_str))
|
||||
.one(db)
|
||||
.await?;
|
||||
tracing::debug!("Directory path query result: {:?}", directory_path);
|
||||
|
||||
match directory_path {
|
||||
Some(dp) => {
|
||||
tracing::debug!("Found directory path entry: {:?}", dp);
|
||||
tracing::debug!("Looking for entry with ID: {}", dp.entry_id);
|
||||
|
||||
let entry_result = entry::Entity::find_by_id(dp.entry_id).one(db).await?;
|
||||
tracing::debug!("Entry query result: {:?}", entry_result);
|
||||
|
||||
entry_result.ok_or_else(|| {
|
||||
QueryError::Internal(format!(
|
||||
"Entry not found for directory: {}",
|
||||
dp.entry_id
|
||||
))
|
||||
})
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Directory not found in directory_paths table");
|
||||
Err(QueryError::Internal(
|
||||
format!("Directory '{}' has not been indexed yet. Please add this location to Spacedrive and wait for indexing to complete.", path_str)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
SdPath::Cloud {
|
||||
service,
|
||||
identifier,
|
||||
path,
|
||||
} => {
|
||||
tracing::debug!(
|
||||
"Looking for cloud directory: service={}, identifier={}, path='{}'",
|
||||
service.scheme(),
|
||||
identifier,
|
||||
path
|
||||
);
|
||||
|
||||
let directory_path = directory_paths::Entity::find()
|
||||
.filter(directory_paths::Column::Path.eq(path))
|
||||
.one(db)
|
||||
.await?;
|
||||
tracing::debug!("Directory path query result: {:?}", directory_path);
|
||||
|
||||
match directory_path {
|
||||
Some(dp) => {
|
||||
tracing::debug!("Found directory path entry: {:?}", dp);
|
||||
tracing::debug!("Looking for entry with ID: {}", dp.entry_id);
|
||||
|
||||
let entry_result = entry::Entity::find_by_id(dp.entry_id).one(db).await?;
|
||||
tracing::debug!("Entry query result: {:?}", entry_result);
|
||||
|
||||
entry_result.ok_or_else(|| {
|
||||
QueryError::Internal(format!(
|
||||
"Entry not found for cloud directory: {}",
|
||||
dp.entry_id
|
||||
))
|
||||
})
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Cloud directory not found in directory_paths table");
|
||||
Err(QueryError::Internal(
|
||||
format!("Cloud directory '{}' has not been indexed yet. Please ensure the cloud volume is connected and indexing is complete.", path)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
SdPath::Sidecar { .. } => Err(QueryError::Internal(
|
||||
"Sidecar paths not supported for media listing".to_string(),
|
||||
)),
|
||||
SdPath::Content { .. } => Err(QueryError::Internal(
|
||||
"Content-addressed paths not supported for media listing".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the directory path for a given entry ID
|
||||
async fn get_parent_directory_path(
|
||||
&self,
|
||||
db: &DatabaseConnection,
|
||||
entry_id: i32,
|
||||
) -> QueryResult<String> {
|
||||
let directory_path = directory_paths::Entity::find()
|
||||
.filter(directory_paths::Column::EntryId.eq(entry_id))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
QueryError::Internal(format!(
|
||||
"Directory path not found for entry_id: {}",
|
||||
entry_id
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(directory_path.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the query
|
||||
crate::register_library_query!(MediaListingQuery, "files.media_listing");
|
||||
@@ -3,9 +3,11 @@
|
||||
pub mod directory_listing;
|
||||
pub mod file_by_id;
|
||||
pub mod file_by_path;
|
||||
pub mod media_listing;
|
||||
pub mod unique_to_location;
|
||||
|
||||
pub use directory_listing::*;
|
||||
pub use file_by_id::*;
|
||||
pub use file_by_path::*;
|
||||
pub use media_listing::*;
|
||||
pub use unique_to_location::*;
|
||||
|
||||
@@ -31,23 +31,51 @@ impl LibraryAction for AddItemAction {
|
||||
) -> 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))
|
||||
// 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!("Group {} not found", self.input.group_id)))?;
|
||||
.ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_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);
|
||||
// Verify group exists if group_id is provided
|
||||
let group_model_id = if let Some(group_id) = self.input.group_id {
|
||||
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)))?;
|
||||
|
||||
Some(group_model.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get max order (either for space-level or group-level items)
|
||||
let max_order = if let Some(group_id) = group_model_id {
|
||||
// Max order within group
|
||||
crate::infra::db::entities::space_item::Entity::find()
|
||||
.filter(crate::infra::db::entities::space_item::Column::GroupId.eq(Some(group_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)
|
||||
} else {
|
||||
// Max order for space-level items
|
||||
crate::infra::db::entities::space_item::Entity::find()
|
||||
.filter(crate::infra::db::entities::space_item::Column::SpaceId.eq(space_model.id))
|
||||
.filter(crate::infra::db::entities::space_item::Column::GroupId.is_null())
|
||||
.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();
|
||||
@@ -59,7 +87,8 @@ impl LibraryAction for AddItemAction {
|
||||
let active_model = crate::infra::db::entities::space_item::ActiveModel {
|
||||
id: Set(0),
|
||||
uuid: Set(item_id),
|
||||
group_id: Set(group_model.id),
|
||||
space_id: Set(space_model.id),
|
||||
group_id: Set(group_model_id),
|
||||
item_type: Set(item_type_json),
|
||||
order: Set(max_order + 1),
|
||||
created_at: Set(now.into()),
|
||||
@@ -69,6 +98,7 @@ impl LibraryAction for AddItemAction {
|
||||
|
||||
let item = SpaceItem {
|
||||
id: result.uuid,
|
||||
space_id: self.input.space_id,
|
||||
group_id: self.input.group_id,
|
||||
item_type: self.input.item_type,
|
||||
order: result.order,
|
||||
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct AddItemInput {
|
||||
pub group_id: Uuid,
|
||||
pub space_id: Uuid,
|
||||
pub group_id: Option<Uuid>, // None = space-level item
|
||||
pub item_type: ItemType,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, sea_query::Expr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
@@ -63,6 +63,30 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
updated_at: space_model.updated_at.into(),
|
||||
};
|
||||
|
||||
// Get space-level items (no group)
|
||||
let space_item_models = crate::infra::db::entities::space_item::Entity::find()
|
||||
.filter(crate::infra::db::entities::space_item::Column::SpaceId.eq(space_model.id))
|
||||
.filter(crate::infra::db::entities::space_item::Column::GroupId.is_null())
|
||||
.order_by_asc(crate::infra::db::entities::space_item::Column::Order)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut space_items = Vec::new();
|
||||
|
||||
for item_model in space_item_models {
|
||||
let item_type: ItemType = serde_json::from_str(&item_model.item_type)
|
||||
.map_err(|e| QueryError::Internal(format!("Failed to parse item_type: {}", e)))?;
|
||||
|
||||
space_items.push(SpaceItem {
|
||||
id: item_model.uuid,
|
||||
space_id: self.space_id,
|
||||
group_id: None,
|
||||
item_type,
|
||||
order: item_model.order,
|
||||
created_at: item_model.created_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))
|
||||
@@ -89,7 +113,7 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
|
||||
// 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))
|
||||
.filter(crate::infra::db::entities::space_item::Column::GroupId.eq(Some(group_model.id)))
|
||||
.order_by_asc(crate::infra::db::entities::space_item::Column::Order)
|
||||
.all(db)
|
||||
.await?;
|
||||
@@ -103,7 +127,8 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
|
||||
items.push(SpaceItem {
|
||||
id: item_model.uuid,
|
||||
group_id: group_model.uuid,
|
||||
space_id: self.space_id,
|
||||
group_id: Some(group_model.uuid),
|
||||
item_type,
|
||||
order: item_model.order,
|
||||
created_at: item_model.created_at.into(),
|
||||
@@ -113,7 +138,11 @@ impl LibraryQuery for SpaceLayoutQuery {
|
||||
groups.push(SpaceGroupWithItems { group, items });
|
||||
}
|
||||
|
||||
let layout = SpaceLayout { space, groups };
|
||||
let layout = SpaceLayout {
|
||||
space,
|
||||
space_items,
|
||||
groups,
|
||||
};
|
||||
|
||||
Ok(SpaceLayoutOutput { layout })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user