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:
Jamie Pine
2025-11-11 08:07:47 -08:00
parent d9f452f852
commit fa5df2d51d
10 changed files with 788 additions and 51 deletions

View File

@@ -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=="],

View File

@@ -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 { .. }));
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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()),

View 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");

View File

@@ -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::*;

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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 })
}