From fa5df2d51d1f138c511f98ea01181c67a2fac903 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 11 Nov 2025 08:07:47 -0800 Subject: [PATCH] refactor: update SpaceItem structure to include space-level items, modify related database entities and queries, and enhance item creation logic --- bun.lock | 9 +- core/src/domain/space.rs | 80 ++- core/src/infra/db/entities/space_item.rs | 29 +- .../m20250111_000001_create_spaces.rs | 25 +- core/src/library/manager.rs | 3 +- core/src/ops/files/query/media_listing.rs | 593 ++++++++++++++++++ core/src/ops/files/query/mod.rs | 2 + core/src/ops/spaces/add_item/action.rs | 58 +- core/src/ops/spaces/add_item/input.rs | 3 +- core/src/ops/spaces/get_layout/query.rs | 37 +- 10 files changed, 788 insertions(+), 51 deletions(-) create mode 100644 core/src/ops/files/query/media_listing.rs diff --git a/bun.lock b/bun.lock index 2363e8902..6bb262187 100644 --- a/bun.lock +++ b/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=="], diff --git a/core/src/domain/space.rs b/core/src/domain/space.rs index d0ae2f146..4d9021add 100644 --- a/core/src/domain/space.rs +++ b/core/src/domain/space.rs @@ -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, /// 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, + /// Groups with their items pub groups: Vec, } @@ -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 { .. })); + } } diff --git a/core/src/infra/db/entities/space_item.rs b/core/src/infra/db/entities/space_item.rs index 003356a67..63fa25d42 100644 --- a/core/src/infra/db/entities/space_item.rs +++ b/core/src/infra/db/entities/space_item.rs @@ -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, // 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 for Entity { + fn to() -> RelationDef { + Relation::Space.def() + } +} + impl Related 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 { - 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( diff --git a/core/src/infra/db/migration/m20250111_000001_create_spaces.rs b/core/src/infra/db/migration/m20250111_000001_create_spaces.rs index 7db35f15e..eb954f83b 100644 --- a/core/src/infra/db/migration/m20250111_000001_create_spaces.rs +++ b/core/src/infra/db/migration/m20250111_000001_create_spaces.rs @@ -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, diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index 3cae6700d..78e3e98cd 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -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()), diff --git a/core/src/ops/files/query/media_listing.rs b/core/src/ops/files/query/media_listing.rs new file mode 100644 index 000000000..fbeeab8b1 --- /dev/null +++ b/core/src/ops/files/query/media_listing.rs @@ -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, + /// Which media types to include (default: both Image and Video) + pub media_types: Option>, + /// Optional limit on number of results (default: 1000) + pub limit: Option, + /// 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, + /// 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, + media_types: Option>, + limit: Option, + 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 { + tracing::info!( + "MediaListingQuery::from_input called with input: {:?}", + input + ); + Ok(Self { input }) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + 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 = media_types.iter().map(|k| *k as i32).collect(); + let media_type_ids_str = media_type_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .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 = rows + .iter() + .filter_map(|row| row.try_get::>("", "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> = 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 = 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 = row.try_get("", "entry_extension").ok(); + let entry_size: i64 = row.try_get("", "entry_size").unwrap_or(0); + let entry_created_at: chrono::DateTime = row + .try_get("", "entry_created_at") + .unwrap_or_else(|_| chrono::Utc::now()); + let entry_modified_at: chrono::DateTime = row + .try_get("", "entry_modified_at") + .unwrap_or_else(|_| chrono::Utc::now()); + let entry_accessed_at: Option> = + row.try_get("", "entry_accessed_at").ok(); + + // Content identity data + let content_identity_uuid: Option = row.try_get("", "content_identity_uuid").ok(); + let content_hash: Option = row.try_get("", "content_hash").ok(); + let integrity_hash: Option = row.try_get("", "integrity_hash").ok(); + let mime_type_id: Option = row.try_get("", "mime_type_id").ok(); + let text_content: Option = row.try_get("", "text_content").ok(); + let total_size: Option = row.try_get("", "total_size").ok(); + let entry_count: Option = row.try_get("", "entry_count").ok(); + let first_seen_at: Option> = + row.try_get("", "first_seen_at").ok(); + let last_verified_at: Option> = + row.try_get("", "last_verified_at").ok(); + + // Content kind data + let content_kind_name: Option = row.try_get("", "content_kind_name").ok(); + + // Directory path + let directory_path: Option = 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 { + 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 { + 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"); diff --git a/core/src/ops/files/query/mod.rs b/core/src/ops/files/query/mod.rs index d1b91c7cb..350fd1b30 100644 --- a/core/src/ops/files/query/mod.rs +++ b/core/src/ops/files/query/mod.rs @@ -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::*; diff --git a/core/src/ops/spaces/add_item/action.rs b/core/src/ops/spaces/add_item/action.rs index bdc8b0a0a..088b77379 100644 --- a/core/src/ops/spaces/add_item/action.rs +++ b/core/src/ops/spaces/add_item/action.rs @@ -31,23 +31,51 @@ impl LibraryAction for AddItemAction { ) -> Result { 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, diff --git a/core/src/ops/spaces/add_item/input.rs b/core/src/ops/spaces/add_item/input.rs index 5c140a96f..aad53ab83 100644 --- a/core/src/ops/spaces/add_item/input.rs +++ b/core/src/ops/spaces/add_item/input.rs @@ -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, // None = space-level item pub item_type: ItemType, } diff --git a/core/src/ops/spaces/get_layout/query.rs b/core/src/ops/spaces/get_layout/query.rs index ad419b419..d007946fe 100644 --- a/core/src/ops/spaces/get_layout/query.rs +++ b/core/src/ops/spaces/get_layout/query.rs @@ -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 }) }