diff --git a/core/src/domain/file.rs b/core/src/domain/file.rs index 217f95419..2835f31b6 100644 --- a/core/src/domain/file.rs +++ b/core/src/domain/file.rs @@ -684,6 +684,94 @@ impl File { }); } + // Batch load tags (both entry-scoped and content-scoped) + use crate::infra::db::entities::{tag, user_metadata, user_metadata_tag}; + let mut tags_by_entry: HashMap> = HashMap::new(); + + // Load user_metadata for entries and content + let metadata_records = user_metadata::Entity::find() + .filter( + user_metadata::Column::EntryUuid + .is_in(entry_uuids.iter().copied()) + .or(user_metadata::Column::ContentIdentityUuid.is_in(content_uuids.clone())), + ) + .all(db) + .await?; + + if !metadata_records.is_empty() { + let metadata_ids: Vec = metadata_records.iter().map(|m| m.id).collect(); + + // Load user_metadata_tag records + let metadata_tags = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.is_in(metadata_ids)) + .all(db) + .await?; + + if !metadata_tags.is_empty() { + let tag_ids: Vec = metadata_tags.iter().map(|mt| mt.tag_id).collect(); + + // Load tag entities + let tag_models = tag::Entity::find() + .filter(tag::Column::Id.is_in(tag_ids)) + .all(db) + .await?; + + // Build tag_id -> Tag mapping using the tags manager converter + let tag_map: HashMap = tag_models + .into_iter() + .filter_map(|t| { + let db_id = t.id; + crate::ops::tags::manager::model_to_domain(t) + .ok() + .map(|tag| (db_id, tag)) + }) + .collect(); + + // Build metadata_id -> Vec mapping + let mut tags_by_metadata: HashMap> = HashMap::new(); + for mt in metadata_tags { + if let Some(tag) = tag_map.get(&mt.tag_id) { + tags_by_metadata + .entry(mt.user_metadata_id) + .or_default() + .push(tag.clone()); + } + } + + // Map tags to entries (handle both entry-scoped and content-scoped) + for metadata in &metadata_records { + if let Some(tags) = tags_by_metadata.get(&metadata.id) { + // Entry-scoped metadata + if let Some(entry_uuid) = metadata.entry_uuid { + tags_by_entry + .entry(entry_uuid) + .or_default() + .extend(tags.clone()); + } + // Content-scoped metadata: apply to all entries with this content + else if let Some(content_uuid) = metadata.content_identity_uuid { + // Find all entries with this content_id + if let Some(ci) = content_by_id + .values() + .find(|ci| ci.uuid == Some(content_uuid)) + { + if let Some(alt_entries) = entries_by_content_id.get(&ci.id) { + for alt_entry in alt_entries { + if let Some(entry_uuid) = alt_entry.uuid { + tags_by_entry + .entry(entry_uuid) + .or_default() + .extend(tags.clone()); + } + } + } + } + } + } + } + } + } + // Build File instances let mut files = Vec::new(); for entry_model in entries { @@ -780,6 +868,11 @@ impl File { } } + // Add tags from batch lookup + if let Some(tags) = tags_by_entry.get(&entry_uuid) { + file.tags = tags.clone(); + } + files.push(file); } diff --git a/packages/ts-client/src/hooks/useNormalizedQuery.ts b/packages/ts-client/src/hooks/useNormalizedQuery.ts index 0efdfa0df..064d25518 100644 --- a/packages/ts-client/src/hooks/useNormalizedQuery.ts +++ b/packages/ts-client/src/hooks/useNormalizedQuery.ts @@ -315,30 +315,38 @@ export function filterBatchResources( !options.includeDescendants ) { filtered = filtered.filter((resource: any) => { - // Files can use Content-based sd_path in some events - // but have Physical paths in alternate_paths - const alternatePaths = resource.alternate_paths || []; - const physicalPath = alternatePaths.find((p: any) => p.Physical); - - if (!physicalPath?.Physical) { - return false; // No physical path - } - - const pathStr = physicalPath.Physical.path; + // Get the scope path (must be Physical) const scopeStr = (options.pathScope as any).Physical?.path; - if (!scopeStr) { - return false; // No scope path + return false; // No Physical scope path } + // Normalize scope: remove trailing slashes for consistent comparison + const normalizedScope = String(scopeStr).replace(/\/+$/, ""); + + // Try to find a Physical path - check alternate_paths first, then sd_path + const alternatePaths = resource.alternate_paths || []; + const physicalFromAlternate = alternatePaths.find((p: any) => p.Physical); + const physicalFromSdPath = resource.sd_path?.Physical; + + const physicalPath = physicalFromAlternate?.Physical || physicalFromSdPath; + + if (!physicalPath?.path) { + return false; // No physical path found + } + + const pathStr = String(physicalPath.path); + // Extract parent directory from file path const lastSlash = pathStr.lastIndexOf("/"); - invariant(lastSlash !== -1, "File path must have a parent directory"); + if (lastSlash === -1) { + return false; // File path has no parent directory + } const parentDir = pathStr.substring(0, lastSlash); - // Only match if parent equals scope - return parentDir === scopeStr; + // Only match if parent equals scope (normalized) + return parentDir === normalizedScope; }); }