From 8a3387ca695aa2abcbe0a65cb59156770287722f Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 23 Nov 2025 03:33:19 -0800 Subject: [PATCH] Add memory system with archive and UI integration --- apps/cli/src/domains/tag/args.rs | 6 +- apps/tauri/src/App.tsx | 14 +- core/Cargo.toml | 3 + core/examples/create_memory.rs | 181 +++ core/src/domain/content_identity.rs | 4 + core/src/domain/file.rs | 3 + core/src/domain/memory/archive.rs | 384 ++++++ core/src/domain/memory/metadata.rs | 69 + core/src/domain/memory/mod.rs | 27 + core/src/domain/memory/scope.rs | 39 + core/src/domain/memory/storage.rs | 439 ++++++ core/src/domain/memory/types.rs | 176 +++ core/src/domain/memory/vector_store.rs | 291 ++++ core/src/domain/mod.rs | 2 + core/src/domain/tag.rs | 17 + core/src/filetype/definitions/documents.toml | 18 + core/src/filetype/definitions/misc.toml | 1 - core/src/library/sync_helpers.rs | 56 +- core/src/ops/files/query/directory_listing.rs | 108 ++ core/src/ops/files/query/file_by_id.rs | 53 +- core/src/ops/indexing/job.rs | 108 +- core/src/ops/indexing/persistence.rs | 75 +- core/src/ops/metadata/manager.rs | 221 ++- core/src/ops/tags/apply/action.rs | 75 +- core/src/ops/tags/apply/input.rs | 60 +- core/src/ops/tags/create/action.rs | 82 +- core/src/ops/tags/create/input.rs | 14 + core/src/ops/tags/manager.rs | 74 +- core/src/ops/tags/search/input.rs | 5 +- core/src/ops/volumes/list/output.rs | 2 + core/src/ops/volumes/list/query.rs | 27 + packages/assets/icons/Document_memory.png | Bin 0 -> 94663 bytes packages/assets/icons/index.ts | 772 +++++------ packages/interface/src/Explorer.tsx | 10 +- .../src/components/Explorer/ExplorerView.tsx | 26 +- .../components/Explorer/TagAssignmentMode.tsx | 7 +- .../src/components/Explorer/ViewModeMenu.tsx | 41 +- .../Explorer/components/AddStorageModal.tsx | 1194 +++++++++++++++++ .../src/components/Explorer/context.tsx | 6 +- .../Explorer/views/KnowledgeView.tsx | 394 ++++++ .../components/SpacesSidebar/SpaceItem.tsx | 27 +- .../components/SpacesSidebar/TagsGroup.tsx | 10 +- .../components/SpacesSidebar/VolumesGroup.tsx | 7 + .../src/components/Tags/TagSelector.tsx | 108 +- .../src/inspectors/FileInspector.tsx | 39 +- .../src/inspectors/KnowledgeInspector.tsx | 180 +++ packages/interface/src/router.tsx | 4 + .../src/routes/overview/OverviewTopBar.tsx | 12 +- packages/ts-client/src/generated/types.ts | 286 ++-- 49 files changed, 4979 insertions(+), 778 deletions(-) create mode 100644 core/examples/create_memory.rs create mode 100644 core/src/domain/memory/archive.rs create mode 100644 core/src/domain/memory/metadata.rs create mode 100644 core/src/domain/memory/mod.rs create mode 100644 core/src/domain/memory/scope.rs create mode 100644 core/src/domain/memory/storage.rs create mode 100644 core/src/domain/memory/types.rs create mode 100644 core/src/domain/memory/vector_store.rs create mode 100644 packages/assets/icons/Document_memory.png create mode 100644 packages/interface/src/components/Explorer/components/AddStorageModal.tsx create mode 100644 packages/interface/src/components/Explorer/views/KnowledgeView.tsx create mode 100644 packages/interface/src/inspectors/KnowledgeInspector.tsx diff --git a/apps/cli/src/domains/tag/args.rs b/apps/cli/src/domains/tag/args.rs index c6a94fef3..d236dc3d1 100644 --- a/apps/cli/src/domains/tag/args.rs +++ b/apps/cli/src/domains/tag/args.rs @@ -2,7 +2,9 @@ use clap::Args; use uuid::Uuid; use sd_core::ops::tags::{ - apply::input::ApplyTagsInput, create::input::CreateTagInput, search::input::SearchTagsInput, + apply::input::{ApplyTagsInput, TagTargets}, + create::input::CreateTagInput, + search::input::SearchTagsInput, }; #[derive(Args, Debug)] @@ -34,7 +36,7 @@ pub struct TagApplyArgs { impl From for ApplyTagsInput { fn from(args: TagApplyArgs) -> Self { - ApplyTagsInput::user_tags(args.entries, args.tags) + ApplyTagsInput::user_tags_entry(args.entries, args.tags) } } diff --git a/apps/tauri/src/App.tsx b/apps/tauri/src/App.tsx index b701b0118..c9ad11d5f 100644 --- a/apps/tauri/src/App.tsx +++ b/apps/tauri/src/App.tsx @@ -27,13 +27,13 @@ function App() { useEffect(() => { // React Scan disabled - too heavy for development // Uncomment if you need to debug render performance: - if (import.meta.env.DEV) { - setTimeout(() => { - import("react-scan").then(({ scan }) => { - scan({ enabled: true, log: false }); - }); - }, 2000); - } + // if (import.meta.env.DEV) { + // setTimeout(() => { + // import("react-scan").then(({ scan }) => { + // scan({ enabled: false, log: false }); + // }); + // }, 2000); + // } // Initialize Tauri native context menu handler initializeContextMenuHandler(); diff --git a/core/Cargo.toml b/core/Cargo.toml index aa473c4f0..cbd7eab50 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -106,6 +106,9 @@ rmp = "0.8" # MessagePack core types rmp-serde = "1.3" # MessagePack serialization for job state sd-task-system = { path = "../crates/task-system" } +# Vector database for memory files (optional for now) +# lancedb = "0.15" # Embedded vector database (conflicts with gpui) + # Media processing dependencies blurhash = "0.2" image = "0.25" diff --git a/core/examples/create_memory.rs b/core/examples/create_memory.rs new file mode 100644 index 000000000..b29592ca1 --- /dev/null +++ b/core/examples/create_memory.rs @@ -0,0 +1,181 @@ +//! Create a test memory file for development +//! +//! This example creates a real .memory file demonstrating the format. +//! Run with: cargo run --example create_memory + +use sd_core::domain::memory::{DocumentType, FactType, MemoryFile, MemoryScope}; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter("info") + .init(); + + println!("\n🧠 Creating Spacedrive Memory File\n"); + + // Output path + let output_path = PathBuf::from( + "/Users/jamespine/Projects/spacedrive/workbench/test-memories/memory-file-system.memory", + ); + + // Ensure directory exists + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Create memory file + let mut memory = MemoryFile::create( + "memory-file-system".to_string(), + MemoryScope::Directory { + path: "/Users/jamespine/Projects/spacedrive/core/src/domain/memory".to_string(), + }, + &output_path, + ) + .await?; + + println!("āœ… Created memory archive\n"); + + // Add design documents + println!("šŸ“„ Adding documents..."); + + let design_doc = memory + .add_document( + None, + "MEMORY_FILE_FORMAT_DESIGN.md".to_string(), + Some( + "Complete specification for .memory file format with custom archive".to_string(), + ), + DocumentType::Design, + ) + .await?; + + let impl_doc = memory + .add_document( + None, + "MEMORY_FILE_IMPLEMENTATION_STATUS.md".to_string(), + Some("Implementation status with custom archive format".to_string()), + DocumentType::Documentation, + ) + .await?; + + let agent_doc = memory + .add_document( + None, + "AGENT_MEMORY_ARCHITECTURE_V1.md".to_string(), + Some("Three-type agent memory architecture".to_string()), + DocumentType::Design, + ) + .await?; + + println!(" āœ… {} documents added\n", memory.get_documents().len()); + + // Add learned facts + println!("🧩 Adding facts..."); + + memory + .add_fact( + "Memory files use custom archive format with magic bytes SDMEMORY".to_string(), + FactType::Principle, + 1.0, + Some(design_doc), + ) + .await?; + + memory + .add_fact( + "Archive is append-only with index at end for efficient updates".to_string(), + FactType::Pattern, + 1.0, + Some(impl_doc), + ) + .await?; + + memory + .add_fact( + "Vector store embedded using MessagePack serialization".to_string(), + FactType::Decision, + 0.9, + Some(impl_doc), + ) + .await?; + + memory + .add_fact( + "Agent memory types: temporal (events), associative (knowledge), working (current)".to_string(), + FactType::Pattern, + 1.0, + Some(agent_doc), + ) + .await?; + + memory + .add_fact( + "Memory files solve context-gathering problem for AI agents".to_string(), + FactType::Principle, + 1.0, + Some(design_doc), + ) + .await?; + + println!(" āœ… {} facts added\n", memory.get_facts().len()); + + // Add embeddings + println!("šŸ”¢ Adding embeddings..."); + + // 4D mock vectors (real would be 384D from AI model) + let design_vector = vec![0.9, 0.2, 0.7, 0.1]; + let impl_vector = vec![0.1, 0.9, 0.3, 0.5]; + let agent_vector = vec![0.3, 0.2, 0.95, 0.1]; + + memory.add_embedding(design_doc, design_vector).await?; + memory.add_embedding(impl_doc, impl_vector).await?; + memory.add_embedding(agent_doc, agent_vector).await?; + + println!( + " āœ… {} embeddings added\n", + memory.embedding_count().await? + ); + + // Test search + println!("šŸ” Testing semantic search..."); + let query = vec![0.7, 0.15, 0.85, 0.05]; // Query: design + architecture + let results = memory.search_similar(query, 3).await?; + + println!(" Results:"); + for (i, doc_id) in results.iter().enumerate() { + if let Some(doc) = memory.get_document(*doc_id) { + println!(" {}. {}", i + 1, doc.title); + } + } + println!(); + + // Show final statistics + let metadata = memory.metadata(); + let stats = &metadata.statistics; + + println!("šŸ“Š Memory Statistics:"); + println!(" Name: {}", metadata.name); + println!(" Scope: {}", metadata.scope.identifier()); + println!(" Documents: {}", stats.document_count); + println!(" Facts: {}", stats.fact_count); + println!(" Embeddings: {}", stats.embedding_count); + println!(" Total size: {} bytes", stats.file_size_bytes); + println!(); + + println!("āœ… Memory file created successfully!"); + println!("šŸ“ Location: {}", output_path.display()); + println!(); + println!("Verify:"); + println!(" file {}", output_path.display()); + println!(" hexdump -C {} | head -20", output_path.display()); + println!(); + + // Verify single file + assert!(output_path.is_file()); + assert!(!output_path.is_dir()); + println!("āœ… Confirmed: Single file archive\n"); + + Ok(()) +} diff --git a/core/src/domain/content_identity.rs b/core/src/domain/content_identity.rs index 01818e4ff..9ee7af9d1 100644 --- a/core/src/domain/content_identity.rs +++ b/core/src/domain/content_identity.rs @@ -59,6 +59,7 @@ pub enum ContentKind { Shortcut = 23, Package = 24, ModelEntry = 25, + Memory = 26, } // Translate database entity into domain model @@ -113,6 +114,8 @@ impl ContentKind { 22 => Self::Web, 23 => Self::Shortcut, 24 => Self::Package, + 25 => Self::ModelEntry, + 26 => Self::Memory, _ => Self::Unknown, } } @@ -305,6 +308,7 @@ impl std::fmt::Display for ContentKind { ContentKind::Shortcut => "shortcut", ContentKind::Package => "package", ContentKind::ModelEntry => "model_entry", + ContentKind::Memory => "memory", }; write!(f, "{}", s) } diff --git a/core/src/domain/file.rs b/core/src/domain/file.rs index 28dd9ee0e..a29ac64ad 100644 --- a/core/src/domain/file.rs +++ b/core/src/domain/file.rs @@ -116,6 +116,9 @@ impl crate::domain::resource::Identifiable for File { "image_media_data", "video_media_data", "audio_media_data", + "user_metadata", + "user_metadata_tag", + "tag", ] } diff --git a/core/src/domain/memory/archive.rs b/core/src/domain/memory/archive.rs new file mode 100644 index 000000000..2f69a4de9 --- /dev/null +++ b/core/src/domain/memory/archive.rs @@ -0,0 +1,384 @@ +use std::{ + collections::HashMap, + io::{Read, Seek, SeekFrom, Write}, + path::Path, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const MAGIC: &[u8; 8] = b"SDMEMORY"; +const VERSION: u32 = 1; +const HEADER_SIZE: u64 = 64; + +#[derive(Error, Debug)] +pub enum ArchiveError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Invalid magic bytes")] + InvalidMagic, + + #[error("Unsupported version: {0}")] + UnsupportedVersion(u32), + + #[error("File not found in archive: {0}")] + FileNotFound(String), + + #[error("Serialization error: {0}")] + Serialization(#[from] rmp_serde::encode::Error), + + #[error("Deserialization error: {0}")] + Deserialization(#[from] rmp_serde::decode::Error), + + #[error("Corrupt index")] + CorruptIndex, +} + +pub type Result = std::result::Result; + +/// Entry in the file index +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FileEntry { + /// Offset in file where data starts + offset: u64, + /// Size of data in bytes + size: u64, + /// Whether data is compressed + compressed: bool, + /// Deleted flag (for soft deletes) + deleted: bool, +} + +/// File index (stored at end of archive) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FileIndex { + /// Filename -> FileEntry + files: HashMap, +} + +/// Custom archive format for memory files +/// +/// Format: +/// - Fixed 64-byte header with magic, version, index offset +/// - Append-only data section with length-prefixed files +/// - MessagePack-encoded index at end +/// +/// Updates: +/// - Append new files to end +/// - Update index with new offsets +/// - Rewrite header with updated index offset +pub struct MemoryArchive { + file: std::fs::File, + index: FileIndex, + index_offset: u64, +} + +impl MemoryArchive { + /// Create new archive + pub fn create(path: &Path) -> Result { + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path)?; + + // Write header + file.write_all(MAGIC)?; + file.write_all(&VERSION.to_le_bytes())?; + file.write_all(&0u32.to_le_bytes())?; // Flags (reserved) + file.write_all(&HEADER_SIZE.to_le_bytes())?; // Index offset (will update) + file.write_all(&vec![0u8; 40])?; // Reserved space + + // Write empty index at position 64 + let index = FileIndex { + files: HashMap::new(), + }; + + let index_bytes = rmp_serde::to_vec(&index)?; + file.write_all(&index_bytes)?; + + let index_offset = HEADER_SIZE; + + Ok(Self { + file, + index, + index_offset, + }) + } + + /// Open existing archive + pub fn open(path: &Path) -> Result { + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + + // Read and validate header + let mut magic = [0u8; 8]; + file.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(ArchiveError::InvalidMagic); + } + + let mut version_bytes = [0u8; 4]; + file.read_exact(&mut version_bytes)?; + let version = u32::from_le_bytes(version_bytes); + if version != VERSION { + return Err(ArchiveError::UnsupportedVersion(version)); + } + + // Skip flags + file.seek(SeekFrom::Current(4))?; + + // Read index offset + let mut offset_bytes = [0u8; 8]; + file.read_exact(&mut offset_bytes)?; + let index_offset = u64::from_le_bytes(offset_bytes); + + // Seek to index and read it + file.seek(SeekFrom::Start(index_offset))?; + let mut index_bytes = Vec::new(); + file.read_to_end(&mut index_bytes)?; + + let index: FileIndex = rmp_serde::from_slice(&index_bytes) + .map_err(|_| ArchiveError::CorruptIndex)?; + + Ok(Self { + file, + index, + index_offset, + }) + } + + /// Add a file to the archive + pub fn add_file(&mut self, name: &str, data: &[u8]) -> Result<()> { + // Seek to current index position (append before index) + self.file.seek(SeekFrom::Start(self.index_offset))?; + + let offset = self.index_offset; + let size = data.len() as u64; + + // Write: [length: u64][data: bytes] + self.file.write_all(&size.to_le_bytes())?; + self.file.write_all(data)?; + + // Update index + self.index.files.insert( + name.to_string(), + FileEntry { + offset: offset + 8, // After length prefix + size, + compressed: false, + deleted: false, + }, + ); + + // New index position + self.index_offset = offset + 8 + size; + + // Write updated index + self.write_index()?; + + Ok(()) + } + + /// Read a file from the archive + pub fn read_file(&mut self, name: &str) -> Result> { + let entry = self + .index + .files + .get(name) + .ok_or_else(|| ArchiveError::FileNotFound(name.to_string()))?; + + if entry.deleted { + return Err(ArchiveError::FileNotFound(name.to_string())); + } + + // Seek to file offset + self.file.seek(SeekFrom::Start(entry.offset))?; + + // Read data + let mut data = vec![0u8; entry.size as usize]; + self.file.read_exact(&mut data)?; + + Ok(data) + } + + /// Update a file (appends new version) + pub fn update_file(&mut self, name: &str, data: &[u8]) -> Result<()> { + // Just append as new file (index will point to latest) + self.add_file(name, data) + } + + /// Delete a file (soft delete in index) + pub fn delete_file(&mut self, name: &str) -> Result<()> { + if let Some(entry) = self.index.files.get_mut(name) { + entry.deleted = true; + self.write_index()?; + } + Ok(()) + } + + /// List all files + pub fn list_files(&self) -> Vec { + self.index + .files + .iter() + .filter(|(_, entry)| !entry.deleted) + .map(|(name, _)| name.clone()) + .collect() + } + + /// Check if file exists + pub fn contains(&self, name: &str) -> bool { + self.index + .files + .get(name) + .map(|e| !e.deleted) + .unwrap_or(false) + } + + /// Write index to end of file and update header + fn write_index(&mut self) -> Result<()> { + // Serialize index + let index_bytes = rmp_serde::to_vec(&self.index)?; + + // Write at current index offset + self.file.seek(SeekFrom::Start(self.index_offset))?; + self.file.write_all(&index_bytes)?; + + // Truncate file (remove old index if it was longer) + let new_end = self.index_offset + index_bytes.len() as u64; + self.file.set_len(new_end)?; + + // Update header with new index offset + self.file.seek(SeekFrom::Start(16))?; // Skip magic + version + flags + self.file.write_all(&self.index_offset.to_le_bytes())?; + + self.file.flush()?; + + Ok(()) + } + + /// Get total archive size + pub fn size(&mut self) -> Result { + Ok(self.file.metadata()?.len()) + } + + /// Compact archive (remove deleted files) + pub fn compact(&mut self) -> Result<()> { + // TODO: Implement garbage collection + // Would require rewriting entire file with only non-deleted entries + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn test_create_archive() { + let temp_file = NamedTempFile::new().unwrap(); + let _archive = MemoryArchive::create(temp_file.path()).unwrap(); + + // Verify magic bytes + let mut file = std::fs::File::open(temp_file.path()).unwrap(); + let mut magic = [0u8; 8]; + file.read_exact(&mut magic).unwrap(); + assert_eq!(&magic, MAGIC); + } + + #[test] + fn test_add_and_read_file() { + let temp_file = NamedTempFile::new().unwrap(); + let mut archive = MemoryArchive::create(temp_file.path()).unwrap(); + + let test_data = b"Hello, Memory!"; + archive.add_file("test.txt", test_data).unwrap(); + + let read_data = archive.read_file("test.txt").unwrap(); + assert_eq!(read_data, test_data); + } + + #[test] + fn test_update_file() { + let temp_file = NamedTempFile::new().unwrap(); + let mut archive = MemoryArchive::create(temp_file.path()).unwrap(); + + archive.add_file("test.txt", b"Version 1").unwrap(); + archive.update_file("test.txt", b"Version 2").unwrap(); + + let data = archive.read_file("test.txt").unwrap(); + assert_eq!(data, b"Version 2"); + } + + #[test] + fn test_list_files() { + let temp_file = NamedTempFile::new().unwrap(); + let mut archive = MemoryArchive::create(temp_file.path()).unwrap(); + + archive.add_file("file1.txt", b"data1").unwrap(); + archive.add_file("file2.txt", b"data2").unwrap(); + archive.add_file("file3.txt", b"data3").unwrap(); + + let files = archive.list_files(); + assert_eq!(files.len(), 3); + assert!(files.contains(&"file1.txt".to_string())); + } + + #[test] + fn test_delete_file() { + let temp_file = NamedTempFile::new().unwrap(); + let mut archive = MemoryArchive::create(temp_file.path()).unwrap(); + + archive.add_file("test.txt", b"data").unwrap(); + assert!(archive.contains("test.txt")); + + archive.delete_file("test.txt").unwrap(); + assert!(!archive.contains("test.txt")); + + let result = archive.read_file("test.txt"); + assert!(result.is_err()); + } + + #[test] + fn test_reopen_archive() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_path_buf(); + + { + let mut archive = MemoryArchive::create(&path).unwrap(); + archive.add_file("persisted.txt", b"test data").unwrap(); + } + + // Reopen + let mut archive = MemoryArchive::open(&path).unwrap(); + let data = archive.read_file("persisted.txt").unwrap(); + assert_eq!(data, b"test data"); + } + + #[test] + fn test_multiple_updates() { + let temp_file = NamedTempFile::new().unwrap(); + let mut archive = MemoryArchive::create(temp_file.path()).unwrap(); + + // Add initial + archive.add_file("metadata.msgpack", b"v1").unwrap(); + + // Update multiple times + archive.update_file("metadata.msgpack", b"v2").unwrap(); + archive.update_file("metadata.msgpack", b"v3").unwrap(); + archive.update_file("metadata.msgpack", b"v4").unwrap(); + + // Should read latest + let data = archive.read_file("metadata.msgpack").unwrap(); + assert_eq!(data, b"v4"); + + // File should still be single file + assert_eq!(archive.list_files().len(), 1); + } +} diff --git a/core/src/domain/memory/metadata.rs b/core/src/domain/memory/metadata.rs new file mode 100644 index 000000000..e451d37d3 --- /dev/null +++ b/core/src/domain/memory/metadata.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::{MemoryScope, MemoryStatistics}; + +/// Metadata for a memory file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryMetadata { + /// Memory name + pub name: String, + + /// Optional description + pub description: Option, + + /// What this memory is scoped to + pub scope: MemoryScope, + + /// When memory was created + pub created_at: DateTime, + + /// Last modification time + pub updated_at: DateTime, + + /// Last time memory was loaded/used + pub last_used_at: Option>, + + /// Format version + pub version: u32, + + /// Embedding model used + pub embedding_model: String, + + /// Approximate total tokens + pub total_tokens: usize, + + /// Tags for categorization + pub tags: Vec, + + /// Statistics (cached) + pub statistics: MemoryStatistics, +} + +impl MemoryMetadata { + pub fn new(name: String, scope: MemoryScope) -> Self { + Self { + name, + description: None, + scope, + created_at: Utc::now(), + updated_at: Utc::now(), + last_used_at: None, + version: 1, + embedding_model: "all-MiniLM-L6-v2".to_string(), + total_tokens: 0, + tags: Vec::new(), + statistics: MemoryStatistics::default(), + } + } + + /// Mark memory as used (updates last_used_at) + pub fn touch(&mut self) { + self.last_used_at = Some(Utc::now()); + } + + /// Update modification time + pub fn mark_updated(&mut self) { + self.updated_at = Utc::now(); + } +} diff --git a/core/src/domain/memory/mod.rs b/core/src/domain/memory/mod.rs new file mode 100644 index 000000000..960b3a999 --- /dev/null +++ b/core/src/domain/memory/mod.rs @@ -0,0 +1,27 @@ +//! Memory file format - Modular RAG for AI agents +//! +//! Memory files (.memory) are portable knowledge packages that contain: +//! - Vector embeddings (Chroma vector store) +//! - Document references (files relevant to a task) +//! - Learned facts (extracted knowledge) +//! - Optional conversation history +//! +//! Format: Directory with MessagePack files + embedded Chroma +//! Storage: {name}.memory/ directory containing all components + +pub mod archive; +pub mod metadata; +pub mod scope; +pub mod storage; +pub mod types; +pub mod vector_store; + +pub use archive::MemoryArchive; +pub use metadata::MemoryMetadata; +pub use scope::MemoryScope; +pub use storage::MemoryFile; +pub use types::{ + AuditEntry, ConversationMessage, Document, DocumentType, Fact, FactType, MemoryStatistics, + MessageRole, +}; +pub use vector_store::{VectorDocument, VectorStore}; diff --git a/core/src/domain/memory/scope.rs b/core/src/domain/memory/scope.rs new file mode 100644 index 000000000..896765781 --- /dev/null +++ b/core/src/domain/memory/scope.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +/// Defines what a memory file is scoped to +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MemoryScope { + /// Attached to a specific directory + Directory { path: String }, + + /// Scoped to an entire project/repository + Project { root_path: String }, + + /// Topic-based (not tied to location) + Topic { topic: String }, + + /// Standalone portable memory + Standalone, +} + +impl MemoryScope { + pub fn as_str(&self) -> &'static str { + match self { + Self::Directory { .. } => "directory", + Self::Project { .. } => "project", + Self::Topic { .. } => "topic", + Self::Standalone => "standalone", + } + } + + /// Get the scope identifier for display + pub fn identifier(&self) -> String { + match self { + Self::Directory { path } => path.clone(), + Self::Project { root_path } => root_path.clone(), + Self::Topic { topic } => topic.clone(), + Self::Standalone => "standalone".to_string(), + } + } +} diff --git a/core/src/domain/memory/storage.rs b/core/src/domain/memory/storage.rs new file mode 100644 index 000000000..d72b75465 --- /dev/null +++ b/core/src/domain/memory/storage.rs @@ -0,0 +1,439 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use tracing::{debug, info}; +use uuid::Uuid; + +use super::{ + archive::MemoryArchive, + metadata::MemoryMetadata, + scope::MemoryScope, + types::{Document, DocumentType, Fact, FactType, MemoryStatistics}, + vector_store::VectorStore, +}; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MemoryFileError { + #[error("Archive error: {0}")] + Archive(#[from] super::archive::ArchiveError), + + #[error("Vector store error: {0}")] + VectorStore(#[from] super::vector_store::VectorStoreError), + + #[error("Serialization error: {0}")] + Serialization(#[from] rmp_serde::encode::Error), + + #[error("Deserialization error: {0}")] + Deserialization(#[from] rmp_serde::decode::Error), + + #[error("Document not found: {0}")] + DocumentNotFound(i32), + + #[error("Fact not found: {0}")] + FactNotFound(i32), +} + +pub type Result = std::result::Result; + +/// Memory file using custom archive format +/// Single .memory file containing all data +pub struct MemoryFile { + path: PathBuf, + archive: MemoryArchive, + metadata: MemoryMetadata, + documents: Vec, + facts: Vec, + vector_store: VectorStore, + next_doc_id: i32, + next_fact_id: i32, +} + +impl MemoryFile { + /// Create new memory file (single file archive) + pub async fn create(name: String, scope: MemoryScope, output_path: &Path) -> Result { + info!("Creating memory archive at: {}", output_path.display()); + + // Create archive + let mut archive = MemoryArchive::create(output_path)?; + + // Initialize metadata + let metadata = MemoryMetadata::new(name, scope); + + // Write initial files + let metadata_bytes = rmp_serde::to_vec(&metadata)?; + archive.add_file("metadata.msgpack", &metadata_bytes)?; + + let documents: Vec = Vec::new(); + let documents_bytes = rmp_serde::to_vec(&documents)?; + archive.add_file("documents.msgpack", &documents_bytes)?; + + let facts: Vec = Vec::new(); + let facts_bytes = rmp_serde::to_vec(&facts)?; + archive.add_file("facts.msgpack", &facts_bytes)?; + + // Create in-memory vector store + let vector_store = VectorStore::create_in_memory()?; + + info!("Memory archive created successfully"); + + Ok(Self { + path: output_path.to_path_buf(), + archive, + metadata, + documents, + facts, + vector_store, + next_doc_id: 1, + next_fact_id: 1, + }) + } + + /// Open existing memory file + pub async fn open(path: PathBuf) -> Result { + info!("Opening memory archive at: {}", path.display()); + + let mut archive = MemoryArchive::open(&path)?; + + // Load metadata + let metadata_bytes = archive.read_file("metadata.msgpack")?; + let metadata: MemoryMetadata = rmp_serde::from_slice(&metadata_bytes)?; + + // Load documents + let documents: Vec = if archive.contains("documents.msgpack") { + let bytes = archive.read_file("documents.msgpack")?; + rmp_serde::from_slice(&bytes)? + } else { + Vec::new() + }; + + // Load facts + let facts: Vec = if archive.contains("facts.msgpack") { + let bytes = archive.read_file("facts.msgpack")?; + rmp_serde::from_slice(&bytes)? + } else { + Vec::new() + }; + + // Load vector store + let vector_store = if archive.contains("embeddings.msgpack") { + let bytes = archive.read_file("embeddings.msgpack")?; + VectorStore::from_bytes(&bytes)? + } else { + VectorStore::create_in_memory()? + }; + + // Compute next IDs + let next_doc_id = documents + .iter() + .map(|d: &Document| d.id) + .max() + .unwrap_or(0) + + 1; + let next_fact_id = facts.iter().map(|f: &Fact| f.id).max().unwrap_or(0) + 1; + + debug!("Loaded memory: {} docs, {} facts", documents.len(), facts.len()); + + Ok(Self { + path, + archive, + metadata, + documents, + facts, + vector_store, + next_doc_id, + next_fact_id, + }) + } + + /// Add document + pub async fn add_document( + &mut self, + content_uuid: Option, + title: String, + summary: Option, + doc_type: DocumentType, + ) -> Result { + let doc = Document { + id: self.next_doc_id, + content_uuid, + file_path: None, + title, + summary, + relevance_score: 1.0, + added_at: Utc::now(), + added_by: "user".to_string(), + doc_type, + metadata: None, + }; + + self.next_doc_id += 1; + self.documents.push(doc.clone()); + + self.persist_documents().await?; + self.update_statistics().await?; + + debug!("Added document: {} (id: {})", doc.title, doc.id); + + Ok(doc.id) + } + + /// Add fact + pub async fn add_fact( + &mut self, + text: String, + fact_type: FactType, + confidence: f32, + source_document_id: Option, + ) -> Result { + let fact = Fact { + id: self.next_fact_id, + text, + fact_type, + confidence, + source_document_id, + created_at: Utc::now(), + verified: false, + }; + + self.next_fact_id += 1; + self.facts.push(fact.clone()); + + self.persist_facts().await?; + self.update_statistics().await?; + + debug!("Added fact: {} (id: {})", fact.text, fact.id); + + Ok(fact.id) + } + + /// Add embedding + pub async fn add_embedding(&mut self, doc_id: i32, vector: Vec) -> Result<()> { + let (content_uuid, title, metadata_val) = { + let doc = self + .get_document(doc_id) + .ok_or(MemoryFileError::DocumentNotFound(doc_id))?; + (doc.content_uuid, doc.title.clone(), doc.metadata.clone()) + }; + + self.vector_store + .add_embedding(doc_id, content_uuid, title, vector, metadata_val) + .await?; + + self.persist_vector_store().await?; + self.update_statistics().await?; + + Ok(()) + } + + /// Search similar documents + pub async fn search_similar(&self, query_vector: Vec, limit: usize) -> Result> { + let results = self.vector_store.search(query_vector, limit).await?; + Ok(results.into_iter().map(|r| r.id).collect()) + } + + /// Get documents + pub fn get_documents(&self) -> &[Document] { + &self.documents + } + + /// Get document by ID + pub fn get_document(&self, id: i32) -> Option<&Document> { + self.documents.iter().find(|d| d.id == id) + } + + /// Get facts + pub fn get_facts(&self) -> &[Fact] { + &self.facts + } + + /// Get metadata + pub fn metadata(&self) -> &MemoryMetadata { + &self.metadata + } + + /// Get path + pub fn path(&self) -> &Path { + &self.path + } + + /// Get embedding count + pub async fn embedding_count(&self) -> Result { + self.vector_store.count().await.map_err(Into::into) + } + + /// Get facts sorted by confidence + pub fn get_facts_sorted(&self) -> Vec<&Fact> { + let mut sorted = self.facts.iter().collect::>(); + sorted.sort_by(|a, b| { + match (a.verified, b.verified) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => b + .confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal), + } + }); + sorted + } + + /// Persist documents to archive + async fn persist_documents(&mut self) -> Result<()> { + let bytes = rmp_serde::to_vec(&self.documents)?; + self.archive.update_file("documents.msgpack", &bytes)?; + Ok(()) + } + + /// Persist facts to archive + async fn persist_facts(&mut self) -> Result<()> { + let bytes = rmp_serde::to_vec(&self.facts)?; + self.archive.update_file("facts.msgpack", &bytes)?; + Ok(()) + } + + /// Persist vector store to archive + async fn persist_vector_store(&mut self) -> Result<()> { + let bytes = self.vector_store.to_bytes()?; + self.archive.update_file("embeddings.msgpack", &bytes)?; + Ok(()) + } + + /// Persist metadata to archive + async fn persist_metadata(&mut self) -> Result<()> { + let bytes = rmp_serde::to_vec(&self.metadata)?; + self.archive.update_file("metadata.msgpack", &bytes)?; + Ok(()) + } + + /// Update statistics + async fn update_statistics(&mut self) -> Result<()> { + let embedding_count = self.vector_store.count().await?; + let file_size = self.archive.size()?; + + self.metadata.statistics = MemoryStatistics { + document_count: self.documents.len(), + fact_count: self.facts.len(), + conversation_message_count: 0, + embedding_count, + file_size_bytes: file_size, + }; + + self.persist_metadata().await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn test_create_single_file_memory() { + let temp_file = NamedTempFile::new().unwrap(); + + let memory = MemoryFile::create( + "test".to_string(), + MemoryScope::Standalone, + temp_file.path(), + ) + .await + .unwrap(); + + // Should be a single file + assert!(memory.path().exists()); + assert!(memory.path().is_file()); + } + + #[tokio::test] + async fn test_add_and_retrieve() { + let temp_file = NamedTempFile::new().unwrap(); + + let mut memory = MemoryFile::create( + "test".to_string(), + MemoryScope::Standalone, + temp_file.path(), + ) + .await + .unwrap(); + + // Add document + let doc_id = memory + .add_document(None, "Test Doc".to_string(), None, DocumentType::Note) + .await + .unwrap(); + + assert_eq!(memory.get_documents().len(), 1); + assert_eq!(memory.get_document(doc_id).unwrap().title, "Test Doc"); + + // Add fact + memory + .add_fact("Test fact".to_string(), FactType::General, 1.0, Some(doc_id)) + .await + .unwrap(); + + assert_eq!(memory.get_facts().len(), 1); + } + + #[tokio::test] + async fn test_persistence() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_path_buf(); + + { + let mut memory = MemoryFile::create( + "test".to_string(), + MemoryScope::Standalone, + &path, + ) + .await + .unwrap(); + + memory + .add_document(None, "Doc".to_string(), None, DocumentType::Note) + .await + .unwrap(); + + memory + .add_fact("Fact".to_string(), FactType::General, 1.0, None) + .await + .unwrap(); + } + + // Reopen + let memory = MemoryFile::open(path).await.unwrap(); + + assert_eq!(memory.get_documents().len(), 1); + assert_eq!(memory.get_facts().len(), 1); + } + + #[tokio::test] + async fn test_embeddings_in_archive() { + let temp_file = NamedTempFile::new().unwrap(); + + let mut memory = MemoryFile::create( + "test".to_string(), + MemoryScope::Standalone, + temp_file.path(), + ) + .await + .unwrap(); + + let doc_id = memory + .add_document(None, "Doc".to_string(), None, DocumentType::Code) + .await + .unwrap(); + + // Add embedding + let vector = vec![0.1, 0.2, 0.3, 0.4]; + memory.add_embedding(doc_id, vector.clone()).await.unwrap(); + + // Search + let results = memory.search_similar(vector, 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], doc_id); + } +} diff --git a/core/src/domain/memory/types.rs b/core/src/domain/memory/types.rs new file mode 100644 index 000000000..af3cef9bd --- /dev/null +++ b/core/src/domain/memory/types.rs @@ -0,0 +1,176 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A document reference in a memory file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Document { + /// Internal ID within memory + pub id: i32, + + /// Spacedrive content UUID (if file is in VDFS) + pub content_uuid: Option, + + /// Physical path (for non-VDFS files or reference) + pub file_path: Option, + + /// Document title + pub title: String, + + /// AI-generated or user-written summary + pub summary: Option, + + /// Relevance score (0.0-1.0) + pub relevance_score: f32, + + /// When document was added to memory + pub added_at: DateTime, + + /// Who added it + pub added_by: String, + + /// Document type classification + pub doc_type: DocumentType, + + /// Additional metadata + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocumentType { + Code, + Documentation, + Reference, + Note, + Design, + Test, + Config, + Other, +} + +impl std::fmt::Display for DocumentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Code => "Code", + Self::Documentation => "Documentation", + Self::Reference => "Reference", + Self::Note => "Note", + Self::Design => "Design", + Self::Test => "Test", + Self::Config => "Config", + Self::Other => "Other", + }; + write!(f, "{}", s) + } +} + +/// A learned fact in a memory file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fact { + /// Internal ID within memory + pub id: i32, + + /// The fact text + pub text: String, + + /// Type of fact + pub fact_type: FactType, + + /// Confidence score (0.0-1.0) + pub confidence: f32, + + /// Source document ID (if extracted from document) + pub source_document_id: Option, + + /// When fact was created + pub created_at: DateTime, + + /// Whether fact has been verified by user + pub verified: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FactType { + /// Core principle or pattern + Principle, + + /// Decision made during development + Decision, + + /// Observed pattern or behavior + Pattern, + + /// Known issue or limitation + Issue, + + /// Implementation detail + Detail, + + /// General knowledge + General, +} + +impl std::fmt::Display for FactType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Principle => "Principle", + Self::Decision => "Decision", + Self::Pattern => "Pattern", + Self::Issue => "Issue", + Self::Detail => "Detail", + Self::General => "General", + }; + write!(f, "{}", s) + } +} + +/// Statistics about a memory file +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MemoryStatistics { + /// Number of documents + pub document_count: usize, + + /// Number of facts + pub fact_count: usize, + + /// Number of conversation messages (if history enabled) + pub conversation_message_count: usize, + + /// Number of embeddings in vector store + pub embedding_count: usize, + + /// Total size on disk (bytes) + pub file_size_bytes: u64, +} + +/// Conversation message (optional history) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationMessage { + pub id: i32, + pub session_id: Uuid, + pub role: MessageRole, + pub content: String, + pub tokens: Option, + pub created_at: DateTime, + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + User, + Assistant, + System, +} + +/// Audit log entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: i32, + pub action: String, + pub actor: String, + pub details: Option, + pub timestamp: DateTime, +} diff --git a/core/src/domain/memory/vector_store.rs b/core/src/domain/memory/vector_store.rs new file mode 100644 index 000000000..eef8a065f --- /dev/null +++ b/core/src/domain/memory/vector_store.rs @@ -0,0 +1,291 @@ +use std::{collections::HashMap, path::Path}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::fs; +use tracing::{debug, info}; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum VectorStoreError { + #[error("Vector store error: {0}")] + Store(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Serialization error: {0}")] + Serialization(#[from] rmp_serde::encode::Error), + + #[error("Deserialization error: {0}")] + Deserialization(#[from] rmp_serde::decode::Error), +} + +pub type Result = std::result::Result; + +/// Simple MessagePack-based vector store +/// TODO: Replace with LanceDB once dependency conflicts resolved +pub struct VectorStore { + storage_path: std::path::PathBuf, + embeddings: HashMap, +} + +/// Document with embedding for storage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VectorDocument { + /// Document ID (maps to documents table) + pub id: i32, + + /// Content UUID (if from Spacedrive) + pub content_uuid: Option, + + /// Document title + pub title: String, + + /// Embedding vector + pub vector: Vec, + + /// Additional metadata + pub metadata: Option, +} + +impl VectorStore { + /// Create new vector store in memory directory (old directory format) + pub async fn create(memory_path: &Path) -> Result { + let storage_path = memory_path.join("embeddings.msgpack"); + info!("Creating vector store at: {}", storage_path.display()); + + let store = Self { + storage_path: storage_path.clone(), + embeddings: HashMap::new(), + }; + + // Write empty embeddings file + store.persist().await?; + + Ok(store) + } + + /// Create in-memory vector store (for archive format) + pub fn create_in_memory() -> Result { + Ok(Self { + storage_path: std::path::PathBuf::new(), + embeddings: HashMap::new(), + }) + } + + /// Load from bytes (for archive format) + pub fn from_bytes(bytes: &[u8]) -> Result { + let embeddings = rmp_serde::from_slice(bytes)?; + Ok(Self { + storage_path: std::path::PathBuf::new(), + embeddings, + }) + } + + /// Serialize to bytes (for archive format) + pub fn to_bytes(&self) -> Result> { + let bytes = rmp_serde::to_vec(&self.embeddings)?; + Ok(bytes) + } + + /// Open existing vector store + pub async fn open(memory_path: &Path) -> Result { + let storage_path = memory_path.join("embeddings.msgpack"); + debug!("Opening vector store at: {}", storage_path.display()); + + let embeddings = if storage_path.exists() { + let bytes = fs::read(&storage_path).await?; + rmp_serde::from_slice(&bytes)? + } else { + HashMap::new() + }; + + Ok(Self { + storage_path, + embeddings, + }) + } + + /// Persist to disk (only for directory-based format) + async fn persist(&self) -> Result<()> { + // Skip if in-memory mode (empty path) + if self.storage_path.as_os_str().is_empty() { + return Ok(()); + } + + let bytes = rmp_serde::to_vec(&self.embeddings)?; + fs::write(&self.storage_path, bytes).await?; + Ok(()) + } + + /// Add embedding for a document + pub async fn add_embedding( + &mut self, + doc_id: i32, + content_uuid: Option, + title: String, + vector: Vec, + metadata: Option, + ) -> Result<()> { + let doc = VectorDocument { + id: doc_id, + content_uuid: content_uuid.map(|u| u.to_string()), + title, + vector, + metadata, + }; + + self.embeddings.insert(doc_id, doc); + self.persist().await?; + + Ok(()) + } + + /// Search for similar documents (simple cosine similarity) + pub async fn search( + &self, + query_vector: Vec, + limit: usize, + ) -> Result> { + let mut results: Vec<(VectorDocument, f32)> = self + .embeddings + .values() + .map(|doc| { + let similarity = cosine_similarity(&query_vector, &doc.vector); + (doc.clone(), similarity) + }) + .collect(); + + // Sort by similarity (descending) + results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + Ok(results.into_iter().take(limit).map(|(doc, _)| doc).collect()) + } + + /// Get embedding count + pub async fn count(&self) -> Result { + Ok(self.embeddings.len()) + } + + /// Remove embedding by document ID + pub async fn remove_embedding(&mut self, doc_id: i32) -> Result<()> { + self.embeddings.remove(&doc_id); + self.persist().await?; + Ok(()) + } +} + +/// Calculate cosine similarity between two vectors +fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let mag_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let mag_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if mag_a == 0.0 || mag_b == 0.0 { + return 0.0; + } + + dot / (mag_a * mag_b) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_vector_store() { + let temp_dir = tempdir().unwrap(); + let memory_path = temp_dir.path().join("test.memory"); + std::fs::create_dir_all(&memory_path).unwrap(); + + let _store = VectorStore::create(&memory_path).await.unwrap(); + + assert!(memory_path.join("embeddings.msgpack").exists()); + } + + #[tokio::test] + async fn test_add_and_search() { + let temp_dir = tempdir().unwrap(); + let memory_path = temp_dir.path().join("test.memory"); + std::fs::create_dir_all(&memory_path).unwrap(); + + let mut store = VectorStore::create(&memory_path).await.unwrap(); + + // Add test embeddings + let vector1 = vec![0.1, 0.2, 0.3, 0.4]; + let vector2 = vec![0.2, 0.3, 0.4, 0.5]; + + store + .add_embedding(1, None, "Doc 1".to_string(), vector1.clone(), None) + .await + .unwrap(); + + store + .add_embedding(2, None, "Doc 2".to_string(), vector2, None) + .await + .unwrap(); + + // Search with query similar to vector1 + let results = store.search(vector1, 10).await.unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].id, 1); // Most similar should be first + assert_eq!(results[0].title, "Doc 1"); + } + + #[tokio::test] + async fn test_count() { + let temp_dir = tempdir().unwrap(); + let memory_path = temp_dir.path().join("test.memory"); + std::fs::create_dir_all(&memory_path).unwrap(); + + let mut store = VectorStore::create(&memory_path).await.unwrap(); + + assert_eq!(store.count().await.unwrap(), 0); + + store + .add_embedding( + 1, + None, + "Doc 1".to_string(), + vec![0.1, 0.2, 0.3], + None, + ) + .await + .unwrap(); + + assert_eq!(store.count().await.unwrap(), 1); + } + + #[tokio::test] + async fn test_remove_embedding() { + let temp_dir = tempdir().unwrap(); + let memory_path = temp_dir.path().join("test.memory"); + std::fs::create_dir_all(&memory_path).unwrap(); + + let mut store = VectorStore::create(&memory_path).await.unwrap(); + + store + .add_embedding( + 1, + None, + "Doc 1".to_string(), + vec![0.1, 0.2, 0.3], + None, + ) + .await + .unwrap(); + + assert_eq!(store.count().await.unwrap(), 1); + + store.remove_embedding(1).await.unwrap(); + + assert_eq!(store.count().await.unwrap(), 0); + } +} diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index 59ba377bd..99282e368 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -11,6 +11,7 @@ pub mod device; pub mod file; pub mod location; pub mod media_data; +pub mod memory; pub mod resource; pub mod resource_manager; pub mod resource_registry; @@ -26,6 +27,7 @@ pub use device::{Device, OperatingSystem}; pub use file::{EntryKind, File, Sidecar}; pub use location::{IndexMode, Location, ScanState}; pub use media_data::{AudioMediaData, ImageMediaData, VideoMediaData}; +pub use memory::{MemoryFile, MemoryMetadata, MemoryScope}; pub use resource::Identifiable; pub use resource_manager::ResourceManager; pub use space::{ diff --git a/core/src/domain/tag.rs b/core/src/domain/tag.rs index 7367c58ae..af87097c9 100644 --- a/core/src/domain/tag.rs +++ b/core/src/domain/tag.rs @@ -10,6 +10,8 @@ use specta::Type; use std::collections::HashMap; use uuid::Uuid; +use super::resource::Identifiable; + /// A tag with advanced capabilities for contextual organization #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] pub struct Tag { @@ -438,3 +440,18 @@ pub enum TagError { #[error("Database error: {0}")] DatabaseError(String), } + +// Implement Identifiable for normalized cache support +impl Identifiable for Tag { + fn id(&self) -> Uuid { + self.id + } + + fn resource_type() -> &'static str { + "tag" + } + + fn sync_dependencies() -> &'static [&'static str] { + &[] // Tags are a simple resource backed by the tags table + } +} diff --git a/core/src/filetype/definitions/documents.toml b/core/src/filetype/definitions/documents.toml index ec55362dc..c086f61a8 100644 --- a/core/src/filetype/definitions/documents.toml +++ b/core/src/filetype/definitions/documents.toml @@ -209,3 +209,21 @@ priority = 100 [file_types.metadata] text_based = true +# Spacedrive Memory Files +[[file_types]] +id = "application/x-spacedrive-memory" +name = "Spacedrive Memory" +extensions = ["memory"] +mime_types = ["application/x-spacedrive-memory"] +category = "document" +priority = 100 + +[[file_types.magic_bytes]] +pattern = "53 44 4D 45 4D 01" # "SDMEM\x01" +offset = 0 +priority = 100 + +[file_types.metadata] +spacedrive = true +ai_knowledge = true +memory_file = true diff --git a/core/src/filetype/definitions/misc.toml b/core/src/filetype/definitions/misc.toml index 86d48c9f9..e5f8e8dab 100644 --- a/core/src/filetype/definitions/misc.toml +++ b/core/src/filetype/definitions/misc.toml @@ -332,7 +332,6 @@ priority = 85 text_file = true typescript = true -# Encrypted/Spacedrive specific [[file_types]] id = "application/x-spacedrive-encrypted" name = "Spacedrive Encrypted" diff --git a/core/src/library/sync_helpers.rs b/core/src/library/sync_helpers.rs index a0253a028..8db8ca887 100644 --- a/core/src/library/sync_helpers.rs +++ b/core/src/library/sync_helpers.rs @@ -42,13 +42,35 @@ impl Library { .to_sync_json() .map_err(|e| anyhow::anyhow!("Failed to serialize model: {}", e))?; - if crate::infra::sync::is_device_owned(M::SYNC_MODEL).await { - self.sync_device_owned_internal(M::SYNC_MODEL, model.sync_id(), data) + let result = if crate::infra::sync::is_device_owned(M::SYNC_MODEL).await { + self.sync_device_owned_internal(M::SYNC_MODEL, model.sync_id(), data.clone()) .await } else { - self.sync_shared_internal(M::SYNC_MODEL, model.sync_id(), change_type, data) + self.sync_shared_internal(M::SYNC_MODEL, model.sync_id(), change_type, data.clone()) .await + }; + + // Emit resource event for frontend reactivity + if result.is_ok() { + use crate::infra::sync::ChangeType as CT; + match change_type { + CT::Delete => { + self.event_bus().emit(Event::ResourceDeleted { + resource_type: M::SYNC_MODEL.to_string(), + resource_id: model.sync_id(), + }); + } + CT::Insert | CT::Update => { + self.event_bus().emit(Event::ResourceChanged { + resource_type: M::SYNC_MODEL.to_string(), + resource: data, + metadata: None, + }); + } + } } + + result } /// Sync a model with FK conversion (for models with relationships) @@ -119,13 +141,35 @@ impl Library { } } - if crate::infra::sync::is_device_owned(M::SYNC_MODEL).await { - self.sync_device_owned_internal(M::SYNC_MODEL, model.sync_id(), data) + let result = if crate::infra::sync::is_device_owned(M::SYNC_MODEL).await { + self.sync_device_owned_internal(M::SYNC_MODEL, model.sync_id(), data.clone()) .await } else { - self.sync_shared_internal(M::SYNC_MODEL, model.sync_id(), change_type, data) + self.sync_shared_internal(M::SYNC_MODEL, model.sync_id(), change_type, data.clone()) .await + }; + + // Emit resource event for frontend reactivity + if result.is_ok() { + use crate::infra::sync::ChangeType as CT; + match change_type { + CT::Delete => { + self.event_bus().emit(Event::ResourceDeleted { + resource_type: M::SYNC_MODEL.to_string(), + resource_id: model.sync_id(), + }); + } + CT::Insert | CT::Update => { + self.event_bus().emit(Event::ResourceChanged { + resource_type: M::SYNC_MODEL.to_string(), + resource: data, + metadata: None, + }); + } + } } + + result } /// Batch sync multiple models (optimized for bulk operations) diff --git a/core/src/ops/files/query/directory_listing.rs b/core/src/ops/files/query/directory_listing.rs index f6c831390..0944ad872 100644 --- a/core/src/ops/files/query/directory_listing.rs +++ b/core/src/ops/files/query/directory_listing.rs @@ -279,6 +279,106 @@ impl DirectoryListingQuery { }); } + // Collect entry UUIDs for tag lookup + let entry_uuids: Vec = rows + .iter() + .filter_map(|row| { + row.try_get::>("", "entry_uuid") + .ok() + .flatten() + }) + .collect(); + + // Batch fetch tags for these entries (both entry-scoped and content-scoped) + let mut tags_by_entry: HashMap> = HashMap::new(); + + if !entry_uuids.is_empty() || !content_uuids.is_empty() { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + tracing::debug!("Loading tags for {} entries and {} content identities", entry_uuids.len(), content_uuids.len()); + + // Load user_metadata for entries and content + let mut metadata_records = user_metadata::Entity::find() + .filter( + user_metadata::Column::EntryUuid.is_in(entry_uuids.clone()) + .or(user_metadata::Column::ContentIdentityUuid.is_in(content_uuids.clone())) + ) + .all(db) + .await?; + + tracing::debug!("Found {} metadata records", metadata_records.len()); + + 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?; + + // Get all unique tag IDs + let tag_ids: Vec = metadata_tags.iter().map(|mt| mt.tag_id).collect(); + let unique_tag_ids: std::collections::HashSet = tag_ids.iter().cloned().collect(); + + tracing::debug!("Found {} user_metadata_tag records with {} unique tags", metadata_tags.len(), unique_tag_ids.len()); + + // Load tag entities + let tag_models = tag::Entity::find() + .filter(tag::Column::Id.is_in(tag_ids)) + .all(db) + .await?; + + tracing::debug!("Loaded {} tag models", tag_models.len()); + + // Build tag_db_id -> Tag mapping + 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(); + + tracing::debug!("Built tag map with {} entries", tag_map.len()); + + // 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_insert_with(Vec::new) + .push(tag.clone()); + } + } + + // Map tags to entries (prioritize entry-scoped, fall back to content-scoped) + for metadata in metadata_records { + if let Some(tags) = tags_by_metadata.get(&metadata.id) { + // Entry-scoped metadata (higher priority) + if let Some(entry_uuid) = metadata.entry_uuid { + tags_by_entry.insert(entry_uuid, tags.clone()); + } + // Content-scoped metadata (applies to all entries with this content) + else if let Some(content_uuid) = metadata.content_identity_uuid { + // Apply to all entries with this content_uuid + for row in &rows { + if let Some(ci_uuid) = row.try_get::>("", "content_identity_uuid").ok().flatten() { + if ci_uuid == content_uuid { + if let Some(entry_uuid) = row.try_get::>("", "entry_uuid").ok().flatten() { + // Only set if not already set by entry-scoped metadata + tags_by_entry.entry(entry_uuid).or_insert_with(|| tags.clone()); + } + } + } + } + } + } + } + } + } + // Convert to File objects let mut files = Vec::new(); for row in rows { @@ -447,6 +547,14 @@ impl DirectoryListingQuery { }); } + // Add tags from batch lookup + if let Some(entry_uuid_val) = entry_uuid { + if let Some(tags) = tags_by_entry.get(&entry_uuid_val) { + tracing::debug!("Adding {} tags to entry {}", tags.len(), entry_uuid_val); + file.tags = tags.clone(); + } + } + files.push(file); } diff --git a/core/src/ops/files/query/file_by_id.rs b/core/src/ops/files/query/file_by_id.rs index 30b49a819..fa6f4bb5a 100644 --- a/core/src/ops/files/query/file_by_id.rs +++ b/core/src/ops/files/query/file_by_id.rs @@ -6,7 +6,7 @@ use crate::{ domain::{addressing::SdPath, File}, infra::db::entities::{ audio_media_data, content_identity, device, directory_paths, entry, image_media_data, - location, sidecar, tag, user_metadata_tag, video_media_data, + location, sidecar, tag, user_metadata, user_metadata_tag, video_media_data, }, infra::query::LibraryQuery, }; @@ -165,7 +165,7 @@ impl LibraryQuery for FileByIdQuery { }; // Convert to File using from_entity_model - let mut file = File::from_entity_model(entry_model, sd_path); + let mut file = File::from_entity_model(entry_model.clone(), sd_path); file.sidecars = sidecars; file.content_identity = content_identity_domain; file.image_media_data = image_media; @@ -176,6 +176,55 @@ impl LibraryQuery for FileByIdQuery { file.content_kind = ci.kind; } + // Load tags for this entry + if let Some(entry_uuid) = entry_model.uuid { + use std::collections::HashMap; + + // Load user_metadata for this entry (both entry-scoped and content-scoped) + let mut metadata_filter = user_metadata::Column::EntryUuid.eq(entry_uuid); + + // Also check for content-scoped metadata if content identity exists + if let Some(ref ci) = file.content_identity { + metadata_filter = metadata_filter.or(user_metadata::Column::ContentIdentityUuid.eq(ci.uuid)); + } + + let metadata_records = user_metadata::Entity::find() + .filter(metadata_filter) + .all(db.conn()) + .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.conn()) + .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.conn()) + .await?; + + // Convert to domain tags + let mut tags = Vec::new(); + for tag_model in tag_models { + if let Ok(domain_tag) = crate::ops::tags::manager::model_to_domain(tag_model) { + tags.push(domain_tag); + } + } + + file.tags = tags; + tracing::debug!("Loaded {} tags for entry {}", file.tags.len(), entry_uuid); + } + } + } + Ok(Some(file)) } } diff --git a/core/src/ops/indexing/job.rs b/core/src/ops/indexing/job.rs index 7ad529c07..9d98339c5 100644 --- a/core/src/ops/indexing/job.rs +++ b/core/src/ops/indexing/job.rs @@ -126,7 +126,7 @@ impl IndexerJobConfig { Self { location_id: None, path, - mode: IndexMode::Shallow, + mode: IndexMode::Content, // Enable content identification for ephemeral browsing scope, persistence: IndexPersistence::Ephemeral, max_depth: if scope == IndexScope::Current { @@ -759,9 +759,14 @@ impl IndexerJob { while let Some(batch) = state.entry_batches.pop() { for entry in batch { // Store entry (this will emit ResourceChanged events) - persistence + let entry_id = persistence .store_entry(&entry, None, &root_path) .await?; + + // Queue files for content identification + if entry.kind == super::state::EntryKind::File && entry.size > 0 { + state.entries_for_content.push((entry_id, entry.path.clone())); + } } } @@ -775,15 +780,104 @@ impl IndexerJob { async fn run_ephemeral_content_phase_static( state: &mut IndexerState, ctx: &JobContext<'_>, - _ephemeral_index: Arc>, + ephemeral_index: Arc>, ) -> JobResult<()> { - ctx.log("Starting ephemeral content identification"); + use crate::domain::content_identity::ContentHashGenerator; + use crate::ops::indexing::persistence::PersistenceFactory; - // For ephemeral jobs, we can skip heavy content processing or do it lightly - // This is mainly for demonstration - in practice you might generate CAS IDs + ctx.log(format!( + "Starting ephemeral content identification for {} files", + state.entries_for_content.len() + )); + + if state.entries_for_content.is_empty() { + state.phase = Phase::Complete; + return Ok(()); + } + + // Get root path and event bus + let (root_path, event_bus) = { + let index = ephemeral_index.read().await; + (index.root_path.clone(), Some(ctx.library().event_bus().clone())) + }; + + // Create ephemeral persistence for event emission + let persistence = PersistenceFactory::ephemeral( + ephemeral_index.clone(), + event_bus, + root_path, + ); + + // Process files for content identification + let mut success_count = 0; + let mut error_count = 0; + + // Process in chunks to emit progress + const CHUNK_SIZE: usize = 50; + let total = state.entries_for_content.len(); + + while !state.entries_for_content.is_empty() { + ctx.check_interrupt().await?; + + let chunk_size = CHUNK_SIZE.min(state.entries_for_content.len()); + let chunk: Vec<_> = state.entries_for_content.drain(..chunk_size).collect(); + + // Process chunk in parallel + let hash_futures: Vec<_> = chunk + .iter() + .map(|(entry_id, path)| async move { + let hash_result = ContentHashGenerator::generate_content_hash(path).await; + (*entry_id, path.clone(), hash_result) + }) + .collect(); + + let results = futures::future::join_all(hash_futures).await; + + // Store results and emit events + for (entry_id, path, hash_result) in results { + match hash_result { + Ok(cas_id) => { + // Store via persistence (this emits ResourceChanged event with content_identity) + if let Err(e) = persistence.store_content_identity(entry_id, &path, cas_id.clone()).await { + ctx.add_non_critical_error(format!( + "Failed to store content identity for {}: {}", + path.display(), + e + )); + error_count += 1; + } else { + success_count += 1; + } + } + Err(e) => { + // Skip empty files or errors + if !matches!(e, crate::domain::ContentHashError::EmptyFile) { + ctx.add_non_critical_error(format!( + "Failed to hash {}: {}", + path.display(), + e + )); + error_count += 1; + } + } + } + } + + ctx.log(format!( + "Content identification progress: {}/{} (success: {}, errors: {})", + total - state.entries_for_content.len(), + total, + success_count, + error_count + )); + } state.phase = Phase::Complete; - ctx.log("Ephemeral content identification complete"); + ctx.log(format!( + "Ephemeral content identification complete: {} files processed, {} errors", + success_count, + error_count + )); Ok(()) } diff --git a/core/src/ops/indexing/persistence.rs b/core/src/ops/indexing/persistence.rs index cbe3bd7ca..aa92c16e2 100644 --- a/core/src/ops/indexing/persistence.rs +++ b/core/src/ops/indexing/persistence.rs @@ -539,7 +539,7 @@ impl IndexPersistence for EphemeralPersistence { async fn store_content_identity( &self, - _entry_id: i32, + entry_id: i32, path: &Path, cas_id: String, ) -> JobResult<()> { @@ -559,14 +559,83 @@ impl IndexPersistence for EphemeralPersistence { let content_identity = EphemeralContentIdentity { cas_id: cas_id.clone(), - mime_type, + mime_type: mime_type.clone(), file_size, entry_count: 1, }; + // Store in ephemeral index { let mut index = self.index.write().await; - index.add_content_identity(cas_id, content_identity); + index.add_content_identity(cas_id.clone(), content_identity); + } + + // Emit ResourceChanged event with updated content_identity + if let Some(event_bus) = &self.event_bus { + use crate::device::get_current_device_slug; + use crate::domain::addressing::SdPath; + use crate::domain::content_identity::ContentIdentity; + use crate::domain::file::File; + use crate::infra::event::{Event, ResourceMetadata}; + + // Get the stored metadata for this entry + let metadata_opt = { + let index = self.index.read().await; + index.entries.get(path).cloned() + }; + + if let Some(metadata) = metadata_opt { + // Build SdPath + let device_slug = get_current_device_slug(); + let sd_path = SdPath::Physical { + device_slug: device_slug.clone(), + path: path.to_path_buf(), + }; + + // Generate UUID for this file (use entry_id as seed for consistency) + let entry_uuid = uuid::Uuid::from_u128(entry_id as u128); + + // Build File with content_identity + let mut file = File::from_ephemeral(entry_uuid, &metadata, sd_path); + + // Add content identity + file.content_identity = Some(ContentIdentity { + uuid: uuid::Uuid::new_v4(), + kind: crate::domain::ContentKind::Unknown, // TODO: detect from mime_type + content_hash: cas_id.clone(), + integrity_hash: None, + mime_type_id: None, + text_content: None, + total_size: file_size as i64, + entry_count: 1, + first_seen_at: chrono::Utc::now(), + last_verified_at: chrono::Utc::now(), + }); + + // Emit event with updated file + let parent_path = path.parent().map(|p| SdPath::Physical { + device_slug, + path: p.to_path_buf(), + }); + + let affected_paths = if let Some(parent) = parent_path { + vec![parent] + } else { + vec![] + }; + + if let Ok(resource_json) = serde_json::to_value(&file) { + event_bus.emit(Event::ResourceChanged { + resource_type: "file".to_string(), + resource: resource_json, + metadata: Some(ResourceMetadata { + no_merge_fields: vec!["sd_path".to_string()], + alternate_ids: vec![], + affected_paths, + }), + }); + } + } } Ok(()) diff --git a/core/src/ops/metadata/manager.rs b/core/src/ops/metadata/manager.rs index fa656aab6..21ba3db72 100644 --- a/core/src/ops/metadata/manager.rs +++ b/core/src/ops/metadata/manager.rs @@ -35,8 +35,13 @@ impl UserMetadataManager { } } - /// Get user metadata for an entry (creates if doesn't exist) + /// Get or create entry-scoped metadata (legacy method) pub async fn get_or_create_metadata(&self, entry_uuid: Uuid) -> Result { + self.get_or_create_entry_metadata(entry_uuid).await + } + + /// Get or create entry-scoped metadata (tags specific to this file instance) + pub async fn get_or_create_entry_metadata(&self, entry_uuid: Uuid) -> Result { let db = &*self.db; // First try to find existing metadata @@ -44,7 +49,7 @@ impl UserMetadataManager { return Ok(metadata); } - // Create new metadata if it doesn't exist + // Create new entry-scoped metadata if it doesn't exist let metadata_uuid = Uuid::new_v4(); let new_metadata = user_metadata::ActiveModel { id: NotSet, @@ -59,14 +64,43 @@ impl UserMetadataManager { updated_at: Set(Utc::now()), }; - let result = new_metadata + new_metadata .insert(&*db) .await .map_err(|e| TagError::DatabaseError(e.to_string()))?; - // No need to update entry - the metadata is linked via entry_uuid + Ok(UserMetadata::new(metadata_uuid)) + } + + /// Get or create content-scoped metadata (tags apply to all instances of this content) + pub async fn get_or_create_content_metadata(&self, content_identity_uuid: Uuid) -> Result { + let db = &*self.db; + + // First try to find existing metadata + if let Some(metadata) = self.get_metadata_by_content_uuid(content_identity_uuid).await? { + return Ok(metadata); + } + + // Create new content-scoped metadata if it doesn't exist + let metadata_uuid = Uuid::new_v4(); + let new_metadata = user_metadata::ActiveModel { + id: NotSet, + uuid: Set(metadata_uuid), + entry_uuid: Set(None), + content_identity_uuid: Set(Some(content_identity_uuid)), + notes: Set(None), + favorite: Set(false), + hidden: Set(false), + custom_data: Set(serde_json::json!({})), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + }; + + new_metadata + .insert(&*db) + .await + .map_err(|e| TagError::DatabaseError(e.to_string()))?; - // Return the new metadata Ok(UserMetadata::new(metadata_uuid)) } @@ -91,17 +125,39 @@ impl UserMetadataManager { Ok(None) } - /// Apply semantic tags to an entry - pub async fn apply_semantic_tags( + /// Get user metadata for content by content identity UUID + pub async fn get_metadata_by_content_uuid( &self, - entry_uuid: Uuid, - tag_applications: Vec, - device_uuid: Uuid, - ) -> Result<(), TagError> { + content_identity_uuid: Uuid, + ) -> Result, TagError> { let db = &*self.db; - // Ensure metadata exists for this entry - let metadata = self.get_or_create_metadata(entry_uuid).await?; + // Find metadata by content identity UUID + let metadata_model = user_metadata::Entity::find() + .filter(user_metadata::Column::ContentIdentityUuid.eq(content_identity_uuid)) + .one(&*db) + .await + .map_err(|e| TagError::DatabaseError(e.to_string()))?; + + if let Some(model) = metadata_model { + return Ok(Some(self.model_to_domain(model).await?)); + } + + Ok(None) + } + + /// Apply semantic tags to a content identity (tags all instances of this content) + /// Returns the created user_metadata_tag models for syncing + pub async fn apply_semantic_tags_to_content( + &self, + content_identity_uuid: Uuid, + tag_applications: Vec, + device_uuid: Uuid, + ) -> Result, TagError> { + let db = &*self.db; + + // Get or create content-scoped metadata + let metadata = self.get_or_create_content_metadata(content_identity_uuid).await?; // Get the database ID for the user metadata let metadata_model = user_metadata::Entity::find() @@ -113,6 +169,58 @@ impl UserMetadataManager { "UserMetadata not found".to_string(), ))?; + self.apply_tags_to_metadata(metadata_model.id, &tag_applications, device_uuid).await + } + + /// Apply semantic tags to a specific entry (tags only this instance) + /// Returns the created user_metadata_tag models for syncing + pub async fn apply_semantic_tags_to_entry( + &self, + entry_uuid: Uuid, + tag_applications: Vec, + device_uuid: Uuid, + ) -> Result, TagError> { + let db = &*self.db; + + // Get or create entry-scoped metadata + let metadata = self.get_or_create_entry_metadata(entry_uuid).await?; + + // Get the database ID for the user metadata + let metadata_model = user_metadata::Entity::find() + .filter(user_metadata::Column::Uuid.eq(metadata.id)) + .one(&*db) + .await + .map_err(|e| TagError::DatabaseError(e.to_string()))? + .ok_or(TagError::DatabaseError( + "UserMetadata not found".to_string(), + ))?; + + self.apply_tags_to_metadata(metadata_model.id, &tag_applications, device_uuid).await + } + + /// Apply semantic tags to an entry (legacy method - uses entry-scoped) + /// Returns the created user_metadata_tag models for syncing + pub async fn apply_semantic_tags( + &self, + entry_uuid: Uuid, + tag_applications: Vec, + device_uuid: Uuid, + ) -> Result, TagError> { + self.apply_semantic_tags_to_entry(entry_uuid, tag_applications, device_uuid).await + } + + /// Internal: Apply tags to a metadata record (shared logic) + /// Returns the created/updated user_metadata_tag models for syncing + async fn apply_tags_to_metadata( + &self, + metadata_db_id: i32, + tag_applications: &[TagApplication], + device_uuid: Uuid, + ) -> Result, TagError> { + let db = &*self.db; + + let mut created_models = Vec::new(); + // Convert tag UUIDs to database IDs let tag_uuids: Vec = tag_applications.iter().map(|app| app.tag_id).collect(); let tag_models = crate::infra::db::entities::Tag::find() @@ -125,11 +233,11 @@ impl UserMetadataManager { tag_models.into_iter().map(|m| (m.uuid, m.id)).collect(); // Insert tag applications - for app in &tag_applications { + for app in tag_applications { if let Some(&tag_db_id) = uuid_to_db_id.get(&app.tag_id) { let tag_application = user_metadata_tag::ActiveModel { id: NotSet, - user_metadata_id: Set(metadata_model.id), + user_metadata_id: Set(metadata_db_id), tag_id: Set(tag_db_id), applied_context: Set(app.applied_context.clone()), applied_variant: Set(app.applied_variant.clone()), @@ -152,41 +260,49 @@ impl UserMetadataManager { }; // Insert or update if exists - if let Err(_) = tag_application.insert(&*db).await { - // If insert fails due to unique constraint, update existing - let existing = user_metadata_tag::Entity::find() - .filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_model.id)) - .filter(user_metadata_tag::Column::TagId.eq(tag_db_id)) - .one(&*db) - .await - .map_err(|e| TagError::DatabaseError(e.to_string()))?; - - if let Some(existing_model) = existing { - let mut update_model: user_metadata_tag::ActiveModel = - existing_model.into(); - update_model.applied_context = Set(app.applied_context.clone()); - update_model.applied_variant = Set(app.applied_variant.clone()); - update_model.confidence = Set(app.confidence); - update_model.source = Set(app.source.as_str().to_string()); - update_model.instance_attributes = - Set(if app.instance_attributes.is_empty() { - None - } else { - Some( - serde_json::to_value(&app.instance_attributes) - .unwrap() - .into(), - ) - }); - update_model.updated_at = Set(Utc::now()); - update_model.device_uuid = Set(device_uuid); - - update_model - .update(&*db) + let model = match tag_application.clone().insert(&*db).await { + Ok(model) => model, + Err(_) => { + // If insert fails due to unique constraint, update existing + let existing = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_db_id)) + .filter(user_metadata_tag::Column::TagId.eq(tag_db_id)) + .one(&*db) .await .map_err(|e| TagError::DatabaseError(e.to_string()))?; + + if let Some(existing_model) = existing { + let mut update_model: user_metadata_tag::ActiveModel = + existing_model.into(); + update_model.applied_context = Set(app.applied_context.clone()); + update_model.applied_variant = Set(app.applied_variant.clone()); + update_model.confidence = Set(app.confidence); + update_model.source = Set(app.source.as_str().to_string()); + update_model.instance_attributes = + Set(if app.instance_attributes.is_empty() { + None + } else { + Some( + serde_json::to_value(&app.instance_attributes) + .unwrap() + .into(), + ) + }); + update_model.updated_at = Set(Utc::now()); + update_model.device_uuid = Set(device_uuid); + update_model.version = Set(update_model.version.unwrap() + 1); + + update_model + .update(&*db) + .await + .map_err(|e| TagError::DatabaseError(e.to_string()))? + } else { + continue; + } } - } + }; + + created_models.push(model); } } @@ -195,7 +311,7 @@ impl UserMetadataManager { .record_tag_usage(&tag_applications) .await?; - Ok(()) + Ok(created_models) } /// Remove semantic tags from an entry @@ -403,7 +519,8 @@ impl UserMetadataManager { }; self.apply_semantic_tags(Uuid::new_v4(), vec![tag_application], device_uuid) - .await // TODO: Look up actual UUID + .await + .map(|_| ()) // TODO: Look up actual UUID and sync models } /// Apply multiple semantic tags to an entry (user-applied) @@ -419,7 +536,8 @@ impl UserMetadataManager { .collect(); self.apply_semantic_tags(Uuid::new_v4(), tag_applications, device_uuid) - .await // TODO: Look up actual UUID + .await + .map(|_| ()) // TODO: Look up actual UUID and sync models } /// Apply AI-suggested semantic tags with confidence scores @@ -439,7 +557,8 @@ impl UserMetadataManager { .collect(); self.apply_semantic_tags(Uuid::new_v4(), tag_applications, device_uuid) - .await // TODO: Look up actual UUID + .await + .map(|_| ()) // TODO: Look up actual UUID and sync models } /// Find entries by semantic tags (supports hierarchy) diff --git a/core/src/ops/tags/apply/action.rs b/core/src/ops/tags/apply/action.rs index fb560dd27..de8884b03 100644 --- a/core/src/ops/tags/apply/action.rs +++ b/core/src/ops/tags/apply/action.rs @@ -1,6 +1,6 @@ //! Apply semantic tags action -use super::{input::ApplyTagsInput, output::ApplyTagsOutput}; +use super::{input::{ApplyTagsInput, TagTargets}, output::ApplyTagsOutput}; use crate::{ context::CoreContext, domain::tag::{TagApplication, TagSource}, @@ -45,7 +45,7 @@ impl LibraryAction for ApplyTagsAction { let device_id = library.id(); // Use library ID as device ID let mut warnings = Vec::new(); - let mut successfully_tagged_entries = Vec::new(); + let mut successfully_tagged_count = 0; // Create tag applications from input let tag_applications: Vec = self @@ -71,32 +71,67 @@ impl LibraryAction for ApplyTagsAction { }) .collect(); - // Apply tags to each entry - for entry_id in &self.input.entry_ids { - // Look up actual entry UUID from entry ID - let entry_uuid = lookup_entry_uuid(&db.conn(), *entry_id) - .await - .map_err(|e| { - ActionError::Internal(format!("Failed to lookup entry UUID: {}", e)) - })?; - match metadata_manager - .apply_semantic_tags(entry_uuid, tag_applications.clone(), device_id) - .await - { - Ok(()) => { - successfully_tagged_entries.push(*entry_id); + // Handle both content-based and entry-based tagging + match &self.input.targets { + TagTargets::Content(content_ids) => { + // Content-based tagging: apply to content identity (tags all instances) + for &content_id in content_ids { + match metadata_manager + .apply_semantic_tags_to_content(content_id, tag_applications.clone(), device_id) + .await + { + Ok(models) => { + successfully_tagged_count += 1; + // Sync each user_metadata_tag model + for model in models { + library + .sync_model(&model, crate::infra::sync::ChangeType::Insert) + .await + .map_err(|e| ActionError::Internal(format!("Failed to sync tag association: {}", e)))?; + } + } + Err(e) => { + warnings.push(format!("Failed to tag content {}: {}", content_id, e)); + } + } } - Err(e) => { - warnings.push(format!("Failed to tag entry {}: {}", entry_id, e)); + } + TagTargets::Entry(entry_ids) => { + // Entry-based tagging: apply to specific entry instance + for &entry_id in entry_ids { + // Look up actual entry UUID from entry ID + let entry_uuid = lookup_entry_uuid(&db.conn(), entry_id) + .await + .map_err(|e| { + ActionError::Internal(format!("Failed to lookup entry UUID: {}", e)) + })?; + match metadata_manager + .apply_semantic_tags_to_entry(entry_uuid, tag_applications.clone(), device_id) + .await + { + Ok(models) => { + successfully_tagged_count += 1; + // Sync each user_metadata_tag model + for model in models { + library + .sync_model(&model, crate::infra::sync::ChangeType::Insert) + .await + .map_err(|e| ActionError::Internal(format!("Failed to sync tag association: {}", e)))?; + } + } + Err(e) => { + warnings.push(format!("Failed to tag entry {}: {}", entry_id, e)); + } + } } } } let output = ApplyTagsOutput::success( - successfully_tagged_entries.len(), + successfully_tagged_count, self.input.tag_ids.len(), self.input.tag_ids.clone(), - successfully_tagged_entries, + vec![], // TODO: Return target IDs if needed ); if !warnings.is_empty() { diff --git a/core/src/ops/tags/apply/input.rs b/core/src/ops/tags/apply/input.rs index 2e4e178f0..99309ce12 100644 --- a/core/src/ops/tags/apply/input.rs +++ b/core/src/ops/tags/apply/input.rs @@ -6,10 +6,23 @@ use specta::Type; use std::collections::HashMap; use uuid::Uuid; +/// Specifies what to tag: content (all instances) or specific entries +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", content = "ids")] +pub enum TagTargets { + /// Tag by content identity (applies to ALL instances of this content across devices) + /// This is the preferred/default approach + Content(Vec), + + /// Tag by entry ID (applies to ONLY this specific file instance) + /// Use when you want instance-specific tags + Entry(Vec), +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct ApplyTagsInput { - /// Entry IDs to apply tags to - pub entry_ids: Vec, + /// What to tag: content identities or specific entries + pub targets: TagTargets, /// Tag IDs to apply pub tag_ids: Vec, @@ -28,10 +41,22 @@ pub struct ApplyTagsInput { } impl ApplyTagsInput { - /// Create a simple user tag application - pub fn user_tags(entry_ids: Vec, tag_ids: Vec) -> Self { + /// Create a content-scoped user tag application (tags all instances) + pub fn user_tags_content(content_ids: Vec, tag_ids: Vec) -> Self { Self { - entry_ids, + targets: TagTargets::Content(content_ids), + tag_ids, + source: Some(TagSource::User), + confidence: Some(1.0), + applied_context: None, + instance_attributes: None, + } + } + + /// Create an entry-scoped user tag application (tags specific instance only) + pub fn user_tags_entry(entry_ids: Vec, tag_ids: Vec) -> Self { + Self { + targets: TagTargets::Entry(entry_ids), tag_ids, source: Some(TagSource::User), confidence: Some(1.0), @@ -42,13 +67,13 @@ impl ApplyTagsInput { /// Create an AI tag application with confidence pub fn ai_tags( - entry_ids: Vec, + content_ids: Vec, tag_ids: Vec, confidence: f32, context: String, ) -> Self { Self { - entry_ids, + targets: TagTargets::Content(content_ids), tag_ids, source: Some(TagSource::AI), confidence: Some(confidence), @@ -59,16 +84,27 @@ impl ApplyTagsInput { /// Validate the input pub fn validate(&self) -> Result<(), String> { - if self.entry_ids.is_empty() { - return Err("entry_ids cannot be empty".to_string()); - } + let target_count = match &self.targets { + TagTargets::Content(ids) => { + if ids.is_empty() { + return Err("content identity IDs cannot be empty".to_string()); + } + ids.len() + } + TagTargets::Entry(ids) => { + if ids.is_empty() { + return Err("entry IDs cannot be empty".to_string()); + } + ids.len() + } + }; if self.tag_ids.is_empty() { return Err("tag_ids cannot be empty".to_string()); } - if self.entry_ids.len() > 1000 { - return Err("Cannot apply tags to more than 1000 entries at once".to_string()); + if target_count > 1000 { + return Err("Cannot apply tags to more than 1000 targets at once".to_string()); } if self.tag_ids.len() > 50 { diff --git a/core/src/ops/tags/create/action.rs b/core/src/ops/tags/create/action.rs index ce77a6d79..9503d9706 100644 --- a/core/src/ops/tags/create/action.rs +++ b/core/src/ops/tags/create/action.rs @@ -1,14 +1,16 @@ //! Create semantic tag action -use super::{input::CreateTagInput, output::CreateTagOutput}; +use super::{input::{ApplyToTargets, CreateTagInput}, output::CreateTagOutput}; use crate::infra::sync::ChangeType; use crate::{ context::CoreContext, - domain::tag::{PrivacyLevel, Tag, TagType}, + domain::tag::{PrivacyLevel, Tag, TagApplication, TagSource, TagType}, infra::action::{error::ActionError, LibraryAction}, library::Library, ops::tags::manager::TagManager, + ops::metadata::manager::UserMetadataManager, }; +use chrono::Utc; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -70,6 +72,66 @@ impl LibraryAction for CreateTagAction { .await .map_err(|e| ActionError::Internal(format!("Failed to sync tag: {}", e)))?; + // If apply_to is provided, apply the tag to those targets + if let Some(targets) = &self.input.apply_to { + let metadata_manager = UserMetadataManager::new(Arc::new(library.db().conn().clone())); + + // Create a tag application for this newly created tag + let tag_application = TagApplication { + tag_id: tag_entity.uuid, + applied_context: None, + applied_variant: None, + confidence: 1.0, + source: TagSource::User, + instance_attributes: Default::default(), + created_at: Utc::now(), + device_uuid: device_id, + }; + + match targets { + ApplyToTargets::Content(content_ids) => { + // Apply to content identities (all instances) + for &content_id in content_ids { + let models = metadata_manager + .apply_semantic_tags_to_content(content_id, vec![tag_application.clone()], device_id) + .await + .map_err(|e| ActionError::Internal(format!("Failed to apply tag to content: {}", e)))?; + + // Sync each user_metadata_tag model + for model in models { + library + .sync_model(&model, ChangeType::Insert) + .await + .map_err(|e| ActionError::Internal(format!("Failed to sync tag association: {}", e)))?; + } + } + } + ApplyToTargets::Entry(entry_ids) => { + // Apply to specific entries + for &entry_id in entry_ids { + // Look up entry UUID from database ID + let entry_uuid = lookup_entry_uuid(&library.db().conn(), entry_id) + .await + .map_err(|e| ActionError::Internal(format!("Failed to lookup entry UUID: {}", e)))?; + + // Apply the tag + let models = metadata_manager + .apply_semantic_tags_to_entry(entry_uuid, vec![tag_application.clone()], device_id) + .await + .map_err(|e| ActionError::Internal(format!("Failed to apply tag to entry: {}", e)))?; + + // Sync each user_metadata_tag model + for model in models { + library + .sync_model(&model, ChangeType::Insert) + .await + .map_err(|e| ActionError::Internal(format!("Failed to sync tag association: {}", e)))?; + } + } + } + } + } + Ok(CreateTagOutput::from_entity(&tag_entity)) } @@ -80,3 +142,19 @@ impl LibraryAction for CreateTagAction { // Register library action crate::register_library_action!(CreateTagAction, "tags.create"); + +/// Look up entry UUID from entry database ID +async fn lookup_entry_uuid(db: &sea_orm::DatabaseConnection, entry_id: i32) -> Result { + use crate::infra::db::entities::entry; + use sea_orm::EntityTrait; + + let entry_model = entry::Entity::find_by_id(entry_id) + .one(db) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| format!("Entry with ID {} not found", entry_id))?; + + entry_model + .uuid + .ok_or_else(|| format!("Entry {} has no UUID assigned", entry_id)) +} diff --git a/core/src/ops/tags/create/input.rs b/core/src/ops/tags/create/input.rs index 6923cc108..a2944ea36 100644 --- a/core/src/ops/tags/create/input.rs +++ b/core/src/ops/tags/create/input.rs @@ -35,6 +35,19 @@ pub struct CreateTagInput { /// Initial attributes pub attributes: Option>, + + /// Optional: Targets to immediately apply this tag to after creation + pub apply_to: Option, +} + +/// Targets for immediately applying a newly created tag +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", content = "ids")] +pub enum ApplyToTargets { + /// Apply to content identities (all instances) + Content(Vec), + /// Apply to specific entries (single instance) + Entry(Vec), } impl CreateTagInput { @@ -55,6 +68,7 @@ impl CreateTagInput { privacy_level: None, search_weight: None, attributes: None, + apply_to: None, } } diff --git a/core/src/ops/tags/manager.rs b/core/src/ops/tags/manager.rs index 055f21535..e1b1fa5a6 100644 --- a/core/src/ops/tags/manager.rs +++ b/core/src/ops/tags/manager.rs @@ -31,7 +31,7 @@ pub struct TagManager { } // Helper function to convert database model to domain model -fn model_to_domain(model: tag::Model) -> Result { +pub(crate) fn model_to_domain(model: tag::Model) -> Result { let aliases: Vec = model .aliases .as_ref() @@ -689,43 +689,53 @@ impl TagManager { ) -> Result, TagError> { let db = &*self.db; - // Try FTS5 search first, fall back to LIKE patterns if FTS5 is not available let mut tag_db_ids = Vec::new(); - // Attempt FTS5 search (skip if FTS5 table doesn't exist) - if let Ok(fts_results) = db.query_all( - sea_orm::Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - format!( - "SELECT rowid FROM tag_search_fts WHERE tag_search_fts MATCH '{}' ORDER BY bm25(tag_search_fts)", - query.replace("\"", "\"\"") - ) - ) - ).await { - for row in fts_results { - if let Ok(tag_id) = row.try_get::("", "rowid") { - tag_db_ids.push(tag_id); - } - } - } - - // If FTS5 didn't return results, fall back to LIKE patterns - if tag_db_ids.is_empty() { - let search_pattern = format!("%{}%", query); - let like_models = tag::Entity::find() - .filter( - tag::Column::CanonicalName - .like(&search_pattern) - .or(tag::Column::DisplayName.like(&search_pattern)) - .or(tag::Column::FormalName.like(&search_pattern)) - .or(tag::Column::Abbreviation.like(&search_pattern)) - .or(tag::Column::Description.like(&search_pattern)), - ) + // If query is empty, return all tags (with filters applied) + if query.trim().is_empty() { + let all_models = tag::Entity::find() .all(&*db) .await .map_err(|e| TagError::DatabaseError(e.to_string()))?; - tag_db_ids = like_models.into_iter().map(|m| m.id).collect(); + tag_db_ids = all_models.into_iter().map(|m| m.id).collect(); + } else { + // Try FTS5 search first, fall back to LIKE patterns if FTS5 is not available + // Attempt FTS5 search (skip if FTS5 table doesn't exist) + if let Ok(fts_results) = db.query_all( + sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + format!( + "SELECT rowid FROM tag_search_fts WHERE tag_search_fts MATCH '{}' ORDER BY bm25(tag_search_fts)", + query.replace("\"", "\"\"") + ) + ) + ).await { + for row in fts_results { + if let Ok(tag_id) = row.try_get::("", "rowid") { + tag_db_ids.push(tag_id); + } + } + } + + // If FTS5 didn't return results, fall back to LIKE patterns + if tag_db_ids.is_empty() { + let search_pattern = format!("%{}%", query); + let like_models = tag::Entity::find() + .filter( + tag::Column::CanonicalName + .like(&search_pattern) + .or(tag::Column::DisplayName.like(&search_pattern)) + .or(tag::Column::FormalName.like(&search_pattern)) + .or(tag::Column::Abbreviation.like(&search_pattern)) + .or(tag::Column::Description.like(&search_pattern)), + ) + .all(&*db) + .await + .map_err(|e| TagError::DatabaseError(e.to_string()))?; + + tag_db_ids = like_models.into_iter().map(|m| m.id).collect(); + } } if tag_db_ids.is_empty() { diff --git a/core/src/ops/tags/search/input.rs b/core/src/ops/tags/search/input.rs index 312ccca94..e320d4a2f 100644 --- a/core/src/ops/tags/search/input.rs +++ b/core/src/ops/tags/search/input.rs @@ -70,10 +70,7 @@ impl SearchTagsInput { /// Validate the input pub fn validate(&self) -> Result<(), String> { - if self.query.trim().is_empty() { - return Err("query cannot be empty".to_string()); - } - + // Empty query is allowed (returns all tags) if self.query.len() > 1000 { return Err("query cannot exceed 1000 characters".to_string()); } diff --git a/core/src/ops/volumes/list/output.rs b/core/src/ops/volumes/list/output.rs index 794f46cb0..b3bd9170b 100644 --- a/core/src/ops/volumes/list/output.rs +++ b/core/src/ops/volumes/list/output.rs @@ -32,6 +32,8 @@ pub struct VolumeItem { pub write_speed_mbps: Option, /// Device ID that owns this volume pub device_id: Uuid, + /// Device slug for constructing SdPaths + pub device_slug: String, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/core/src/ops/volumes/list/query.rs b/core/src/ops/volumes/list/query.rs index e8fbb1c30..cbe790289 100644 --- a/core/src/ops/volumes/list/query.rs +++ b/core/src/ops/volumes/list/query.rs @@ -13,6 +13,7 @@ use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelec use serde::{Deserialize, Serialize}; use specta::Type; use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum VolumeFilter { @@ -164,6 +165,13 @@ impl LibraryQuery for VolumeListQuery { .all(db) .await?; + // Fetch all devices to get slugs + let devices = entities::device::Entity::find().all(db).await?; + let device_slug_map: HashMap = devices + .into_iter() + .map(|d| (d.uuid, d.slug)) + .collect(); + // Create a map of tracked volumes by fingerprint let mut tracked_map: HashMap = tracked_volumes .into_iter() @@ -184,6 +192,12 @@ impl LibraryQuery for VolumeListQuery { let disk_type = Self::infer_disk_type(&tracked_vol.device_model, &tracked_vol.volume_type); + // Get device slug for this volume + let device_slug = device_slug_map + .get(&tracked_vol.device_id) + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + volume_items.push(super::output::VolumeItem { id: tracked_vol.uuid, name: tracked_vol @@ -206,6 +220,7 @@ impl LibraryQuery for VolumeListQuery { read_speed_mbps: tracked_vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: tracked_vol.write_speed_mbps.map(|s| s as u32), device_id: tracked_vol.device_id, + device_slug, }); } @@ -215,6 +230,11 @@ impl LibraryQuery for VolumeListQuery { for vol in all_volumes { // Only show user-visible volumes if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible { + let device_slug = device_slug_map + .get(&vol.device_id) + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + volume_items.push(super::output::VolumeItem { id: vol.id, name: vol.name.clone(), @@ -231,6 +251,7 @@ impl LibraryQuery for VolumeListQuery { read_speed_mbps: vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: vol.write_speed_mbps.map(|s| s as u32), device_id: vol.device_id, + device_slug, }); } } @@ -243,6 +264,11 @@ impl LibraryQuery for VolumeListQuery { // Only return volumes that are NOT tracked and are user-visible for vol in all_volumes { if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible { + let device_slug = device_slug_map + .get(&vol.device_id) + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + volume_items.push(super::output::VolumeItem { id: vol.id, name: vol.name.clone(), @@ -259,6 +285,7 @@ impl LibraryQuery for VolumeListQuery { read_speed_mbps: vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: vol.write_speed_mbps.map(|s| s as u32), device_id: vol.device_id, + device_slug, }); } } diff --git a/packages/assets/icons/Document_memory.png b/packages/assets/icons/Document_memory.png new file mode 100644 index 0000000000000000000000000000000000000000..d32fcbe436c49990f8542310ba445807d18d617e GIT binary patch literal 94663 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�_gf34Ax0XOYt6OyqO|S zzTWG4ds0Jrybh8(poJGtV9R6AoaUGmZ)~*Z@qMnFB=U)uICO0fIr@9Pla4aiKk4mb zM5hgs2hy8V9?2`C;*pYxnc{FldTpDk&7u9iX^ozGIHubLRG0bEe$RZWl|HEje*O;d zv$ecX-v{3o5&>Ch0Dpa!1{V0s0w2WR=9%C8v0wTdU-v@`3k%oS#&_fB(W3)?zen$X z|NF~lpM4fNfhtb`R9z2z*TAkN07SOnyLSWSzv9}pYY-C`CzA>O_Vf7z554K@f3>Vi z{DnZ;5X{QKWAi*#>e~N@!qP`%s&E$p_LC0;q?&AOem6 zEU~VrR}RlV`yapmPrvfX|KBsKtE=nG&<)tmSN?;4@DGf;+tW`!4NoAw8wtR!cLT+D z=mVJ((C>WbJLyYb`cj3kaTtK{S8H=5N=}eK-6-NdQx%I!IKkTeje_o7oD|4PH?Prg z^rt*mgUTR1ouT?tj~=hBp*nHLEVmjKTF+l=%1YF}Kk~RRMzV@Xu(KYaQWIf^UQ$1O zs&7$_Gxx`JvX`!%-0NsGKR>BzYMigfl6TAW>Eq3`_94?g%{xo_V-Nc`&b>C=;6`lVme?wvBXXgZ^D zXTk1SX#(0-2vV*pnAcRoKXHNon7yFYUaxoH!8g78S4*P;vJ!wt7L&}++Ty+4A)bhb0=yba-x_^wc|=jfWq(4e~ScAk5!pWpB_*SGE@bln8=`=kK9D? zSFgVuosT4d$snu=r6U9JN+csA0W`RGO_Vpc#_RTbED(A_{X>*h$EB*MwtH4z6OS27 zVkE_uNYasg`guXUxbna|=txyoCtTfHsoA5+A6pzx4n4+z3Ua41A9hb3yQgag*X7z1ZD{UE@Qr&97%y&B6?l-Lp6U~LxkW|(=ghA&`xmK z0-;L8rg@bBBz?02CR26A42i#7cbEneSTHeMa2BoD=EHo{DDv zdacuGQ!pO8ef0fHV(9nR&n?VP`!od7qpHd2#}ZofVJ{)7p92+5j=YgNB`Eki^tNwf zL+?MQ0VDqnW?Q%T77VpO{_Z+(`{nPxOx5`twEL75ExdGcfurFt?G1V-sZG^00 z9F+jJS&e3l#e6^=xcB?M<}ZKzuk*XTpX~x*_&N_i{BW1;fCU&q#|-Wo*wt>JPysnN zP~!!3_Uu`ME};VA&jRJ-oN_}rN5Ji_iQsC`F~X8+juesW_P00{Lu=W>tBZ&tf|IB* zBFL`4HsQ|~Y&BYl0_NCWB!gm~l> zVp|nHwF(4x`L2im>|6iJC-l?*_eaf6YB?`xg{xBC) z(JK%dsLdv(z>H2+VvbnYCD>$Ps7rwBi6~O5`TYq9*GV>%k&2>gwT_5L*iWK{I;|mP zQA${-p@fn|BB!DY*US49-490oU;C|e><@iEZSnTv56ItUSgP$pU8wxeuX60VQZEHg3&r7K;GPY+Bxxe1;c`6ZP zHrCv$h2sy?aYp`|%hHJ zX-D`E9%V&$X?FWUW%mUzK68a8S2n2s=yBsliiTh>%q^VQH)i?pgMap`|KiWz_{{ge z>xcOnU;nWm`?1NPLx)P%3RG`>>sy`n?ar12c6}r;bbcrZ^T;EQ(34L-srUiVu54U@ zrl(1aTs#5{jZ$_os}86+SJONr9fVJ(H&2O(9X^g4o`!@P(gO)jH5p$bwXDHf5EAK4 z<5fw%O{~k;wYN|_u?9l+?EtMnQoo}y#F$xd?`pIZuYU963w2!7XShBC$-3HzU_6g_ zUSWy++1q!5PJR17X0BgbS5NJcziZFPf5lKPCF=H9nEWFzG2 zn%En%a{R0R?~nhPfA!qC|L~W8j@3cco8SEA!3!_EKp+4NB^X4PE%c5g0*ZDiIF~Q- z!C))_6pZ-0{17ZIFE1}mCX)l)23|m)u^ezoi%a{R>Ry{bu%thtte;8LC`P{2L_7^- z;;zfJZCO`>&|_+p?LQr7${1OdR!+r{%%d@G?zm1=Ihi%&kS`Inj@34<`$1N3jz593P$v**U9!>4z1d( zWMmJdKi5G>SVkN_6mdXm&)&k+Yruv8&v zY?-@QE~@pjKlAthhZjEfzkZ%oK${=69!;g3ellp?K?~x{B45eD`eP}Q${?#8vTvJPs&<(r8JISd zsG8D#Bv&ua%YIa5t=aV@{m8zeYlXBD%~z<8qPeNKO++r+q^+c%-1_S-ymV-vj-&7W zZkp^nN!t@d_bQXQ)9@ltpG_(neIZ8v0HlG=8teF)a=b~yweyAm+{pl<2I7W4sEHQ# zpQO&>v6i;+bAgEH;Zx@=5m>NJ3KQJ7%F5;}UF|#Z`~SiZT=>L4|KmkbOi(ufZNBTf zzH5RckwjkWXk5qdC?c@yD}pf~6@nzhqr(+yM>*c2?dzv$aO2B&Jn~PWVzt(A;~Z^WeU7D` z>&eMHm*$B8Bm=Fjt%Yxa%_+osp=#maJ0AP1zx+S3 zI}LWV5g0cVd4Nz75>dbaPvMaa&eQ}>@T|Nx0;%N9sDPx-KgVIFx_lyPa+%83V%6!m zuCZz(Kbg5IlfQqlMxd^f(P*12YgAtoWyq%-LM1bdXK`VAOc~|IpWkJy#eWT51gPw z@BJP|{=CPfF!;L7AH8tJq;zNl5D?Yr7#;s;;}se^#HZi|GoH|H1MCHy4F>BkQ;&&Z zf8{7YR(+ag6bBK2gtY*hfVRmaCR7<>j9LS$cRliFzWV?4gTHv@KYjnNvOTawT+sX8 z_r8j4gfd?c-AS~R4^!l~&QL3dgd0#Tf#h;hnIa!1p zUs!XN{(H*#CkUwa0?It38DxbB-=e{}DpRRzJl+oTuO)+eTxG8Bn-{Rsl_Mg`B7ezo zn~#DzEIpLQ#nU1@jG-Fp6+0(0b|QVm{WV{}2qCMCPQPy1s?w^ZC!)*%$z1hJMBuAn znfka;{e08+P`U3E4O#VrRL+%hIe)G`Q4ii-zmxte2G^2cmE1)t+Q4{M^&dE5oS<=^pbml|zRL%Wz59Rs2T%X_U;ZOUj~<<@ ztgIj{O!d)^eiS#`7*_5yd%&($0XZ9h`3|37c>lco^2>y(K@HFnxtgm3f(~kiHpC>p zTSOK2+xVW-YZEn%vyw9v=Tg;8gJ?K_s)n+htct$b`o4Y!=>m&*+(gAyH}fy6P<|!r zwQ7(=(;e*(O*z+8mF1CQ9r4>(ic+n8>s3YgJ1itwEWly+2HGuzvcc- zKWy|JpSeb}V6b+9wy%9TLjGkI+O=!_!C=t;#3w$1R1r`Y7swjw>`iwB71;GQ z0AClF52;h9PNDv--rn9Ol#lCnyY3D5*VvyS2w!s0QUxR*4NpQO;!KUUM2P`c^pSj~ z{_COfEHy=f^x)QNjJZlhwgSVK+KiR7Fd8_?=TnvtI59RXQF)ajA;emARrP7u5`CrXhP z>NKc*rs@3wKxXiKG5t;^k?O+(qG7AytsCI=T0?FR0RGvC4u7c#~<&XIdi5r91d5?vP3yaTW)PN+#l~m*vTFsUI0X38Z3Uz zNmBb@H(--6Y+n4iSZG@5utad?v;XvS|KwZ#1)sdKZ{NPHlP6D(SyNC^Eu)h7ye(l@ z69A{oGdY3Q*VpmA_x$tEFWk6sW0`OGJ{JF6I_Q!SQ$5PLnr#Jh#&yIT@j#_OeKOn|as_eEh|oT?hv$Ui~A#u$v^?&fD{(nu)T&J7E~ z61Xl#f)&ya2o;PRyFijT>`^7!MU$rah+OMli0i%jh$t(mM0?hT{1u6t9Pe?&{qwp} zj_{s8@xwG)Ib}S5hDP;M81WHn-~Rl|#vs`SgSB%s-n`T}-dS0qTvYI`2Y!YoTTN-I zw{(~m58l^$8G^(X-*%759gTYkBGBc(B_;yPMY(bM-~ZJ=e&w_O`giWR=brTk9(Z5~ zBEYI4lPUu0Ak)WfECuX6NdnHFw+1A({1Zz7$=>MPM}ymMXn&->I|n-d>@*OSL_Wzi z4ZXKYdlHAFp%Mt%C2T7V@~2o;MRlL0X*YC9+e+atPl4ch)e_p6wnz?F3H1-vk_O0| ztrX_k>Dbunb1!A}^YP;IbTJ@oxi8*mpK0&MYPFS6to{*m^TA$WWl;Tm(|1#~deW#A zCgfBYYu{X;rvrtaRj zgTJcVbtV#r$qkaCL^Yvel%GTTzQZhJ48HcT%D2@5p=&>snl?Z@j!OEIt<)4`Dv3it z&Z0keE|;2tA_K4>1PO9ay{6JRMss!jnfpQ+P-$Zl2%Jc_sK%PAC-IpfjLMz&pXhhr zBk!e^$3AF^mYZVjzAdgFO=xuHDzyRf_`r~vT~Yz5Y1bz3oeHpnnB;I7z=;QIEZpc z2KS!#yhlRw2J0G4}?{l>*rI&M)M-FF%aY>jTv*0L@ezy zniu!mU!kM#|DS9{ugY?h7WvyY!{^}jlOG2Wu5k6tP64{_rA1c+y`DMX+(5lv&*}Iw+obh$&ORP2{>*}Iwx8aLnqEFSiw5;P$aMz@*v}(R zELtQN${<-ybuX%!Kd3P$9Yhh?dHv~CVNxX~hN|hS4xc$ssW96as=N9DaXZf{HF*70 z@cKbbTAYX7#1U+$e#$9cKOz<{k=F(DhKxOC~#rS3~Fy;S_dFZ@CQ zub|u6Ep*q)lel5;d*Ay^AsD`)>fP^tw`L08VYN@^^5x45ECDRAT4?d`k^8>e6gT1j zdc8Wzu+sCp-KlxbO;dF=XHVI*4^B;^vK_Vb7OAn+ltBdwZS|_5yvikQAE~37YL)c6 z3VkcGPV|&tgH!J~)odV&{(@RyQPF1@PV|>o!PTu)BJ}_XEZzThI`RkqnkepKkw3~7 zs!>V9=P$Pwae=Nnr}{yV7m(VkLjFKdl|}Xyyu2m@nZ-@~8l>8(1d}gc$^Nu!$o&7dug>kq+&mxU+mW2Tx5mJUVrK2U2py7;TJyk z|NR}t3lo+E$}fE33&d*U3I$|zZ&Lq z_JHdjWek-?ugfZ>{`|<0=P}vbs(-R!zhYz$ZhtN*@D_psF3QMNRn#R$JIhP+MLjDk zF_puSiJg#uXyKKGw@_Pr00P98oL3N_G7D}^ zM2DV?B&n5_&9!bp?rp!Cw zz^!BcScg#&sU4=g01;SRF#9NBXR083A8mjbSyj*n|Hj{C+u-t-zx?H;2v(34nD> zwy#+gQMSSKJB)qAeo8Ra(gy9x_r@<@XIl-%vuQh^w1Yt((w$yB^!A7T=s)>p))Xv% z?sK2(|Hg0pM)B!Se_Ht*LCzj<+fdxyYy|dhT*eg?z`B3q)4RO9Y|=rSdH|6MgeVv^ zbGKOOx(UT93oZVoxX0OZZTwR8@mbH>R2|d&&uPGkVH!7qWanJ)j!2UZS(3!D_FmM~ zgL+CMzw@eYs+`Y18-9WOq4zi3zpf=EBzoJ4>c=4eexIg6H5@in8!+sa!!;gzNOvS? zNCel-I-|p^>z|bd1N)g00jPr7y@C`CpSx(Hw5r;JvbZ*M*<~=$cbc29diD|lY)0m&CWwRiE$2X1I ze>I8gEG3gPfj~dJa^Kr&`K{kdBLsX^?h$Y{%*my78o#pM+Fz>rDHi(Ekv{_M82MM@ zNu0B(ngmseD(+0b4u#@EhREHB3fmh3Fdj2;7*1tqu&49}toU^4|pZ z0Nm%!!tuBNrJwySKE86|#EC^Xj+{MvwgZnKb(i+E+J8a1BpPY}siLRSgOKJmIo4`KcMIxy* zulFxP^Z8EHJp8+GX`)-bZ2T{@E9#Wenrexuq5RSHXtKAI#s(yvom&&;$v%xTrv)EK)sIPqZS>W>sS_=?2L}0@pWbI3x^m|HLmbE* z8Yd9e1tN%I_Y*7WctQw?2>UbDZo~TvyMTwOsvy26xPN*$4on7O1IF<1nv|-4>TAAp zZEbCZH3f^THtrudaG>+O-}}AFUh35Zw?6Fps1gK%Ar8R*h6FGe3@W~{wTKYX7f|p- z%z>V+L1;jZ`_zI&WNinzai^co3~Z5ew+CCtO8CnaY5^e0TYq6SYFiY-?j^jVOh5hQ z(9R@>lk;@#dS>ip(dTkBc4O_C^~D6z5vg$fY~&w0Ng(YrZm*o5xu(wkW7NC%?LY{o zs7U48D;Hfln63Iz&SRzMPI1UH$bUOZ2W<+?Oh!x)HXHLp5&1g_LeU!mShsO!70KVQ zC)lS{O%(OT`wVX)-`{jq5TAd}rsS|*a!>^!tvBBfwQ&4x-}fK>dsY)Kec=mV=$$!p zrucJz?#~rqD7|X=c8Cqw^+sTXbW1NF+_c5T#jr0{jqkO;ihN4wsU2sIskz#z#<{kk zcEn;R)o9=8EQkUub`(?h*Sp<0q~3A0fFo565O{Zq(sT_#>Zu9>#3L>AV(JPf$83qp zg|N1IR>@Mt_lD6qQXfsX0o1zuoGcgj8B|NIJ-mL5?w`5;5jcIr{ksnR7YdBj8(`(!17DMWzxySr=y#-^%*-~q(O;0gv| zE3lgPABO%BOJK|QzWv*n5!!d-#*GD*01E!>yzs&cp=QAC0J}OBg!BT!Cn|zL0`S@W zShpwG3!8W$)%{2BxYYP$I7e!`&JQ6JOLMrp)4Y$E&gj)m!FJi1gQC-m=39hz8BSPC z(q;iIs2S;NiHL-v$aA~J9Qx~sJD<|7P5o38*4mh(Xx6={37y$@5QT+hTKSr9HHe_p zwRDJW9bj7i|7x&KX(aJHM8L)hSR&A_3bOk? zI(xOg_sZ6tAM-!O(vipS|HJ>!_pGd}EN^dbBaOV7C~N`3E9jP%0`^Kmk}U~|&*R6B zn@?v1CZ7vP=G?J*>Zqe^|0xHcbhmj(-!)ixlN;nL+P32_U~u$c$=IlV=jWVc#ZOgVri)w&R)z4A;(OR6+I$ncNt< zx(zDW1Mm~Xf8Rsj@Xbgc&yv7GS(XKoO8P2;ekybe!=4uqSqT6)?v+taNHAXC^L9o6C}bn=4;kHmP9g*MUyiOh&c7WWz{{r^v_$Xf z(s2KB!n#GxQQRSQf?K(NAb+t(W?x#E8tkX{p2`nmC9=`><;ca<4rHGZB=W1d1Iq+X zp#G!I%A?=@gI~Y7xw&-Y$dNuXjb_5IE&75nw=(SJTtLzWKuopu{rBImapMpOw!Xg3 zk~ChTS}mcdMhHn~_GOgpy@QzB_wJo_PZAMkziI6dS;^j&G#7X%itj*2qO5*{gi!9lRqm{?s8&~awHF^IDwo&`Y7F4l?^UhUZb_G3RW3}Qn|ItbtYp2 zp$VkZ=#y0TyZ^0-T1jAzXiNjyR|whDG2z8NEPvJSGu(e!+47)%Ruh*#K>5x*6jeudF5|azv za60;o`)q&yRLil{b0~*s>)stI*0JQG!y4N4?*3RNp+h||c~zKB`f5)#r`ptY(VVG4 zQvA7y$VYpuseAO{fc))gXZ*do)f|Vp^U1Ki0vNzV_fA3n4b_i4HJM@uXts8Uf6J;m z^c&2W;u!(b9<>vK*1)zF+tf5wkU0+`Fe7zDIa%lX<_M^GKuU9eJ!4>{K|BY=>RoSL zKK15fYy@7oa^*@FuAs;d#2NnR}|Ir5U@fK`oI8L?`gBmwdAnt2j>E7U#W2!L%6?s>TubDyf$F}nD2+`|2f z)DF`itNQU>B)af=@H8N-x^)nL1`HuE$XiH*ZN_ZuTT5L9uL_z5vrv>Ucn(UQ%hAXG@FT;MXpz)ZC@asl0Q z&pqZ&)set4kJ(TSG!RxsRgq8ykBFoZH9N=fPYYNUJNoT);dLJ>+pkq06*l;*(=)nO zS@|s0g`*u}0?^jC%aK6n_0h)kDRlhNe6-yI+oo${5}e*Vrxx@3HA_u43%n6G*bJP3 zeHziMg4AW(E*yKJ^=*eq3$X)`5tgmo?5G}2qqi|0_T4}@yb#so}CS&zO{dgHj+1SPxNUU zkS4;nMcRgLr&*{3QNKt7(G6`EslTuyTwonhF?}wFHeu|dNq}l~)HFg_6N>a$4pQkL z?K>qlCSufyfW5nz0Ki}l2_UHIEO+R?7kws{&M-U4aif{k4*7|;tA3(pRl6NPS0j@$ z;a24O+rU%_<3Ba80XPG*R6(YnDXAkuo+h3{vCs|Adbv5WPls~}wl*(Kos|<0udc2x z9X)!q2Nlp0Pdw3q9RMn3sb;>_U{~7!a&B6v4a`&=5aoC`GvNp+BXn#X z=(y-&-)FD4kf?tA^$XE_{n_+b3KF5`2(yHGzD+e$ZHLfdzwG{!RXbGh`+nGPeU)t( zpj;bRwF@JEpE?Ab{?*rnmpdFI>DE~vmFJsLsqOmg92YAMs_8k|MQU4+ z+*LNNf_S zrrMM<#Ml1+<_C55AGhwUeu-Y4H!2PCmrO!Kb<>vGAve!CR6ljw974pr$3(ZN?s+Dd zd1GBoB2ZQHsj{qh)9k`HIzt42{O$hN(lzdHwK*0HgOdQXu?-#N?+ceNU+%B1trai6 z_+kfkMMMS5+(5UQF?iRfg9boX_tB4jl%>6n=9_15?ZU!Bt*;57n)+<}#kI^J^ch57 z8iM@;fn>W$iK!#F zlE5Xs*4=vvMR54i_Tu}HF&6E{I(N;&sux;Zd$RE#RTbSwz+@!2|0$^*axjnTr=D8V zf8Xf9wZ3fw!mRp7Hzb%=qjw8b!`V`R?YEgaq9MJRo2?^_fVmd)>9X#V@hPj1dlxTW z>@guIe(Se>3o0PPXx5jgTS^_YtBb+|K%r3-etmsiK>|Q32sY+au;L>XgonU58j-Iy z_BrO2h+6dj0=Y<)O+)Xjn#858uc%+aiF>_d+Sg3bg4RiTllI|WfNArNr(8<`v6SFX z>D)z)e$z2LLCdYvEJ24>p=-&}+bJkYgY44vrz=5&&i<1$G~X0?`fHXlYUYut#~9*b3b$T;fEhabwTxk z4}1U#Ce@Gp$d4%7z1Hd$lgRc8!mtVmL;&I%BmfYArKKhFUiW%EmEtq}_61KQ7a7zY zEn`V{M)?o~6+=UHp=%_iR70&HRX}ZCHvWoWB&7_8t76W-?uX*G9{C3qSdCB<3kBuC z&r3^UlamUFn*HkL+@-YS=Nfj%-KSJ~k|X5QaKWx3_gTdXzM{7(xPN791K=6RxFuHQ zV0vnY7LeRO`iD1B;fk4b7ujgOvO?X}RsLM1&N3r-K2{shbu*p8Z4TPxj&l3U>%@9H zOUrf-%s%jbEG?OeU)U!|J<&Th9_(m5pE@G1g5&$)JVMNqQaB!v|Cx+Oy>7SLyK?1< zxf!Szj8DV^0N3o8G5ZR@t|b6)|HLPdKXL)V4HUoO350B*4l2+2nSXVScv3{}A-%mp z4dGF-H!soa2~R0R|alVYTX8bs9`vd4xIvU9{CLi!T1Yl;w_o2ckqM`vpSsB%k) z<{oj{o@nNXk5|R0xS!a_E2JNHF05A)TT*c!RPTFg{Po`>FCi!LdoLjEDU70I$tsba zO5t>^cS+k zp|i;Q)>USIRi{JqaN)Rb};E?YA{reRHOW{<4 zcmNQ9Hk|QB!mcF%Fw8g`fJQb@SnW@qJc%4Y8cE=s4WQ5-VUl)CaIKawp!0g@+7L^% zp_FuFG>!XG4L^0Fn%Hp(B`HbBvY!4kA)+cK5PtXkUYPBW z5>ZXT7Xx+}~4xop07kSnODW+M4E0H>_=93C>aAz)o?^$*%W z5(lLwW`B|Nt;2h6_kGFS2jmCJ5eMAA(i3n&_^sN_Js;+~dmgJ2h6`hK>G%6xl!^3= zrKl1{w-W4n8vshU;q%#NpVjBjpEt)`GB|t7l@0ke5Y!f$B4#$6_6S7=#q*N!`7<^2ad-MB%)<_!H>C`n|J+Y7hQw`gRu6H-5MvL~BNa*)n zZ@N}PB{#$DUrli9`zCwz+pB|gA_$#F4qhUuEEzTRXFVdH2br5(IXgpg;$q|Te0c>q;sk$B2ss+#BQo=QoJumh5(#%^Q z^$1@a^9V3`Hazzk>%D7as{%H6Be{OG;8MIKQFFTWWG0ibOW!;(oHvm2gmtBr=dwIq z;B({gwmEOJfL2s8LdPF@$;hpAJQ^5+;oBk6+hlCLQM~F1a{)n^I6rICj>nv^!40XS zn=d1Ymrh5=rIer~x3B6Quetc-uK0VZb#yO7>mVi7_sHyiQ4;{wQT1|d(sG_V z$jv{G%VO)bw`huo*B07UL33R_@BT`)66^^@!VLKP@7opjoh$XEKo!BYwKX`CR8Kzn zWO4fR>39yem>cM>mH@mR0I;~YXwpHm-YzhEhzd3*Jy+P^`fH9qoG~0h9mVQKBcF62 zGF*zjf=VW7R~v_I|IP_A;)0c!lq4*vS5TzdfX4$u<_%)kK~kt!=m?3Mlh5^^a^AJ7 z$Rba)Ye+b%#!Y_4s1Xnqh7nwNBxyyfBB6$hC4^|-b+n44CrK2=*~BK2s=l^9vY7oQz*U!;ptg*($uk-@D64aOL?6Eo;C&R>t}JsDuBc|TuZj$mPAw6np-Z^imbxbZ%P$vY$yGaFD}%0s)61gW4oU=Jh$ZkeUqD%xhSqTx;%ACwg&@td*B2G=&*Ugcy0{=z5P- zMC4S|R=c@cv012P93Bn`Tv4pnsh8^ByLb(9t#%6?!Sv^dVnQX?;Z-@55ZkB?z7@8e z$q}<`$0}-~Suj$lMz2!;o^$>5#HVzrwadx{e(4ZXfc!a85L0RO<=rWj$AC;>Tvlx8 zEOUlx*b12vtucx1Q;t35W6Sgzr@0Lr(v}*|f z0D3uZ2)^sCyQ<5VFZY1_;RcF=FccV86$m~gIHd&Q(iHT}K@Fi|PM;|D)0Ywi3C`O~ z0r7LIBMzTpLBVk+6g}+bv|ws2!z>X!MMNj|nvujv4p^^Mnw~=o!HEX~6*zH0nm#y8m%+(S``G)Apv-qjKZX7 zM_}wH%#p+Eqy@>hUh0dvq{kT%O*S`;7tFNslf{CqP-cjL$#FC=aR@h;IIi9?qI&PF zx@>``v0(BO70#n+DnNa>wUy78WMTkDrcZOP4Z&*?&m3{ky@{=ru%mnA7%cyOBe>24 zq$%@fa)5DvH@^6ZYpgXWq2{@G@nQ*fA8zKXUN&WC9lnV-=?#WmO#oajAVZyzxCw?} zUlKBc2wTNSsP;%9id7gf=d2P=-kukT)*(o%o}dUBCHP$+DHRijdQgG<#h4NaD{|R5 z`+?(zN~TP7tVZNE89Vy{;n^~gBfC1e7yN>CJBtpP^2=dT?`~!K8H#STqvQEEU^#S)uFzMs#7iSou zCFh|t$66=C1c_}y9hOR#kC2*y{E@C1>ZxfSGsfXyhB_xl95k^6r@DbSP*Lmh=P}rK zR(ke*@wDIl#|G-{@pwGJn20CZw{IUEKYpB^e)?%*<}$6_ex-QqT0SmLQ1Fy3CJjLoLV}TD12Fug0U=A^*Nb>P0Qa0wLQZZ{GFPzSv!pIIezFKsb=S(=9O&kY0DmrIl6= zSr_(N$8p)-v>B(1gm8`kVh$L$;}X%uFNjyu=z6mh<&lGEXXmBrHH-bLo2$^jbJC4z zs-WjS&`J+C>pc@lpy+y)pF`|bVYq5UmX^%PPBypf_W}INN36G>Se<>P&TT%6kJLc`TKLZHP~a&_oIKUhpzZb9zX<3sC{Pbhp9Ms z6Dl`H49t51;(&m!k*yDa`(y4C{V*MP$ss1S@ArKZ(Mkp5&wjl$BONV zma;Rv=RNO%n>qPdfLjfAvlmc!!ankmkEr|ZyU!GaaoIr4lg&4?Xgt#bnc6S)gRdR- z2*}6bP>&$J4m{E^P~6^otpxQ4CFW#_9F^c%g+5U0FR<`Jx-V370Ubc${~H(hU3$0d359y*1u1*<4ReBDk@-k-qf*JIm@q%A--D0D0p z`Mr${;3SENA`<=id*H8+2oJ-EC4itVs#itDO5dr7N-D>ti6m_x`6+NS{`))8X+ym< z+1hLn42%3NLN-hQ0?!se+?Du~^UrbqIgYkIUi9I6zpp09V$8Z*ZRS^v<_~0hHC|Iw z`xV4Nm)hOW%1H5@Rr`#3HL#J8&5a)*d>h<-dGO#tWE6l~xxptWIg5Z(5e=;97J*$| z6yC=G09a>Nm1WsIbm$QB0^yHU5|h9K)?7WPrZNS=gSx>sX}kuWERYtG00~utz;Rk9 zbRma~1Y-C7`L;^1t(p|2VrI}^db&^))R5eh_uLy~BPK5raSBpWB9h2j>w41u8b5#W zI}*3W@i1Rffr1*=;MQ$W1a-C+Wrn~(r@N;FctfT=+O~jKC)4 z%v<03R;Zn6PX%-f03-n19O&lAp@4htxkq2UdNu3`8+1Z@6<_OFfJpLrhA8QqSc{crc1}3w1>F1Y(yFKm#>jrTAHIio z4a`8dzqtD7dylf(xL`t1oI7`}SY2H$82NWV006gQKA_#43@+5`^fdth`}gnnfnab0 z6$nn?#zYMxk*dXiwQFc&P*QbYne$q3VouIqhVw@^?%`Bc{HVDzs45WC)KWY(5U4p1BOJqv%=~Ym5Fe z-T%k_DV_Wu{t+#|<2y-u3VAS`VYfbtq+~r-r^G=-uOYme@{T&>f~fErA&KCY=n-&+ zISj~-d}mEHj;guU#)a-{WG{97knQDxOyC>o1zBRFujPUqQ&792K2wk(;Qu|2RGHC;kP|k%wtxW0RSKO*jXHqA*c6 zNeM%EyL6V8OhoBS*rBxWYW(wcH8kEsFb+>PH*QLjcvS#>KaY!qYu1hR=7Z68ybp8i zvoToneJd80o99-Xqe5WDY(bdqt##UjdhN)=fBN_QtzZ8E#wp7z0rY?MSAW&uSXM0; z!U+62aRobvT`d8~l92EM^0|OkR#r6MJQb;c0)8nXjwN9@1GgIc(hH<1Z@r>ORbF=* zw~@zGgn%>Bi2qFJgq9)dwa(~UN9#?YpLyuPle zGdr?a{}}Zj_Db90M^qajts4^Yu$v_*ZsxCo+?ols{v4sa@pnCk~}fodUF z7g(vUfG}<%XuX?d{eY09vdn^O6V?fC3WJ>%0#cLTlV# zmlhgh7fGO*m+<84i$?;?R9Tib{9}wb5M~mJ&T8-8-}}z$bH8wjRYTkB>+2=r0q(l% zE`wieJlGp=3)t1!Kp~F7KNkQM5C{NZad8or{94G99IQ*5+>W5q7kkR-1VP91qo$6b zeVBHL2#Ic}iK2EyO;4D57qXB>_LTH^@+u|;&z73IE_#c(Nt^utMK9sK)DF`V+yn<{xc|mR zA-E~e2gYS7=NV5vT&ks5ecDs~GU@7OirMUJA5ub#`+&LtIl`lgdSCRUG}&LR%2pD7 z#`i;;@fQ1qEW3DK22AAphaY&$($dm0YZUqqKKLNh{-w@a-}=@)3E&n1gC5`p%DOod zu6^y=HPrAl=a>N0cQ~;8_H&vSqdy>3Ho;yf2}g?>Bm7h~S3OVrEJ)<<4n#H!{}s(K+6bi3Xk=^3zoBsQZcwqqxheP@tQ z^Ps4e>u#v3(oJhS@9upI0iaGd+lP??c38l8Ctg+(p_}Iv`u^UOdxXezjr(nD33T=? zSdL#j{zlC?+OC&^;_~B(`BS<$#N7V^+YT+zU{?o% z;nN3$A!i5Ro_p>wB_WyPV@7W*aE0<%Ij^meT%@A9NDC3&EU}kdLTYSIe>cF7pQvB_ zf=u3?-cmdUR6>(92a94=m`mp%`i3DFe{v3pA`Ur&!V7|zVMrmUj4aWyDIyB79&Ks| zw0d=@mhaObS-XTR`Et{?5!;%Em%c<5+l73Amk^^}S!ux9RJ6PKjMo;sCI$e3U_ctk z8fg-cwRqW}@)sNpyh;c;YBbH4?;duR9bpY{^E&0|-ILv>0tywlQtCQIbd}I1xfMmr zbw)2qJ5vMgpsib{Nj-uw_#n_8qu5Q zo_d*|kv^(}-F^4n9oQCq|Iz{Fjf7nt2qx1(AZJI?L^XZ{6-tavq6>3T-mUr*n%jw? zX-8=9Q+#C40V3q@IbRTkZmhO2)xd!Kb$q=^{G##c|6VHIpYPalBvM)qd?lOn#cc{`l(0Fj=bN}fj)+9_248~wK0(D35>0*%78|@Cd>)k+c6IcxdB0!ffUk-PpkAfAx zaIF?>b>@EQ7HO8Iap`J~Hbn6=KC8bS-~;&P7nzqo)f2;w)3j@hfdCkhX4e}|GcQ+~ zvj(+)O_I4}ZC>RtQ@u4WWcF80*-I(hK}}F@UZMi-iO@5b$t9J|*B|h|vfNa$7!!bk z*NGfFCAUXG@~OG``wJ%e6rMNpB#`qe$@!tF!fDl^n|qc}{Lsg=2R`9e@H}-gk`10I zSMwmA-~8)Q#eDp$a#B{YIK$19iIuHB#)Lc}#;PkL_yr^2yrdk9Xl@DYWqSMaIsRF$ zudi2({7Ysg3*-l48>9M>ANdjAt!7ow&KCU|!>)G&CHLf_APkJb4?OTdg=9`RQ740w z7gENBPripj7ECWB9V4Y_Qo>FU;7uS)xL|geD=pm1X>!uL{QVN(vtOdjV_|iocK!J{ z0K^N_bps6lXOOx<>|&0kt6vLo+-`|0h~lz4zGe;*JMAZmm2e6C)?Q-K|9<{%_5RAe z{3U|H^bw+9A+k5NyDxSHRW6$<_II*1F!2Bl4Nevlx@=P-&maPdY|VxFK{tN4 zAyHj=2_VH!x-pAs_$!w-!(5dk3OmMwvv07ie70ny&}pu=CVMMO{hlK)dNBcjf!}Cr zDq$PPXJyqyK^R0l0lzE5i!Z#u&(CmaX(@0&tOnAsFT(B!%>xp{z0t6nRY2YVte^s7 z0#G0s9MZSu+B!A2fxtCopVt{I{cDBXrVNn>)o6_3>|<7C;Lj9gpNz8o0NJBpJP^EX zJIsjo+WGdocH?bZWWJhY*Ng)2W^C6EL_yDVubT7RtAwKCL=#3B)!JF3no?fj>!2Es zYPZlz*SEJ5zfw_FP^cGJA6yMv485+5p|QmvEpvZH)vTQuOS5g&oGzfYZT%_#rrn5X zuAnU(&#PWj4IsA2d>RnHYKwOm*#JFpf0c@ZzB&utpz>4hn2C=WS=U<67~mWf2OBSb z_Bqxdj9HT~VM0*h6P|@1`p}0W8^9Y4yV?j0DvUrdlxut8g%>pH0k~YQ33_#)lRq`9aSZwAPt8Z9z7o%_y2*1lJ2_qJcR}Q#%N<{O#=OuHVHvc?M9ZrI zlqkmBCILu5#VZtv$=L-YQh=){R6?BvmKwU9Cc@Z}0kj*wMV~WWKy!A0wuer~eUq0Y zGSJBzyw#2j;fW&wxNrFNFZz935KS}vLXz9sHuociNCM#Hzy;K6IRnCn{ADD;TGGKY z1F3rb^rv6k-rgQ>ZEa1U0s?M%@x>QWeXubLdIMos8-aatCPV_Wp++MHz**(B4+Kk@ zQWaTKv)$7CGoV)oDKoYc&^tacCwzst8hz6zTR_!UX`icw?Eu$bge{0)>>_Y}M%sZ| zOV^A+P#qXoP(du9I?Es*!#!>fnjEr9(@AuUZ2)Sa?&_-HXpJ$m*K4WX)waB*1UO9v z6uW??+3nZapg4zh0?qL+Z1y5ba_9zt1D)IkcJ!eh8rrTWM8rGK>j%+ zwYg_1c6+uV1Ug=!3;vR?AD)wH>&k^{fc|NGxxq4cZ|KF!s$ z_)5dBE(r;M6yT{cashb=*+3J};@?OU2EH2KyD_pUI(b!&Cjux*7-l$w0|w#* z@&^&lr6JFo5|ALiEe7;Xr2n^Vx~q%zZyyP!jk6C~^`|3W zyrycjc9l7Pgf)+t09411AD^tPtyRDBE5D*qcN|&=B!+`0kbZ-!gY9Y?fCqR1`S0E@ z*GB@IyHh{U78;CD&M?+?Zr1VF3)Gy)W}n*Gn&x@F=hCeu?R`5n-zCv?DX7L*lQ0^( ze;8U}2T-o+n)!1TgeNvRoK%V6-)`axni>>Ss9=1YIF7s6i4I&@<0d#^MueSZj~G|2 z*9)YCPCG$K?G+wk7toYLmntEhIo}v%iYsVA^4`lx-NHydti$JZbocEu>+-pUuwHxZ zPNZ$;&^MWw{hr>o6V1O1^$*oKz_cR*kOIwpbIt+A+lc2qxb*DvAOLIt9&K!FjM?xz zIdbF(z5VTPHw%RX;8Q}p(XiWd0U1h+-w@4E3t<2L{pL3$07Pl($oS{6L(2J3f?MW5 zG*jWVU<#5x^fXa@#(lw8PA-3e5mG^+ zkdB*P3GS~7TA|>+nsWu628Pgt)Z;sRC3B$@timWvErMlJ<2OhNi>pR$8 z+Jx`FL=RvRy4?5Z(sO5+fgF})IbtGUjKEi~UJV!e0#vRV;TsCOSp@_)EMo{pQFt6< zQB7UHemy){lgUJRl|WGaXbO?9bB-jw?*y^!wC@>xs9YO)ET`+uwbhhI?BchU2!Lq* z^`v9v)|4YaZA2iIEOOV0Gb(s<+q^%h@r!&t*UuQ&KFI+ZH4qyBEoUew0CR?L8I1a! zLI6x!f+cCJ+?i)KqFZOSQP$fh5QO~)jQXTl$V(>HE-?>*pFKO%UyB_8#o`Td+JDeU zFPM+z?&s-$XQ)f|2_x~e972)ArmX=udXyX!5b9iL_Xl~AiQ1Ll`nS)s-hZ3N94{{~ zSEw?0-+lL0KHW^{`V9^O1MKENFy$+Pp=KxCKT!}yhGvh)V{7Y?{@6kNJPo>Cb+e+&#uXiI=G|8859bek|4-(H5y6+)IZC-}v$;9HHUlioVmA7i8-O zjIsy-DJD9`1=02iSJQfIO|yxH7E_!?6yiBH30S?Kx*_S=mwLj6;N`*^f=d(?*Y4$I z)rRt(K)R-}#t1B;+1d)kTjZYvhsp1Vw-}GCQ4$hJ&XZ*)jIdB`Oyh!U6_9)m(&sKG z<)!z`9&dF8ZOmaOd42K=H_zInH1R9OjW)P|im_Z^SS~HMJsr=>xPVff`$@ZTs-cVq zxBCCl^|Pn>>wvWfW7hwdAOQT^xPT4@wg$6{0nkc@7QaETYYBi)$n6Tk;HH@4H*VaZ zB^L$Q>-9pl!RZ-0993uy3C^_7&no%ehVXBOm{4>r5PGL03tSh}Eqa95w<<(vTBKg* z4RY5SB;R2IIUq??I-LcV_s3n!eZo^?Jlcwg0NNlQkepK&oc4r7fLR-|P7u1%bL>US zwNnYGU9aI3$J?ZaS9#4#E_l^)`_*Vbk-^jr0iAKwYl{S65|U-p5En+bJPE;IK0r~gF9$8 z)&a~4?>~&e>3iD<0^8TOL?3+`+UPU^<3V^9%o_v)@YXA5`RkCi2Sa8cC(KkL7C^E7 zzl5v#U3cAO26F~r{YHBM?P>{t|AP7l$>2Wrv5)Bk2M$30ub6rpimi1MX~djj4gsYv zMGkq}PZf9ye@^z15`nah{GIQ=Ikp8`tVA`&L4~l|1<)dErJ4l9=$Y}rS5{pBuHUAX zXd(hs{xZd1{O?AZWP1&WQdv%tImK;eLyE-HeQe`k>uW;wG<;T86=duHcfHf8fm|Z6 z$uNoz_G1+b9g_*Pa)IRD6;uXS6$|}#gD)fX$@Y4KJ2lT8+!qB$ZA%o6EJ5gWrXo?O zi{{zIDpCRE_AEz8r$}3KmwTKp0a)&SV&|t2fjb>}n(VgB<@Z?4lminb%=f|MY6{1s z3#jAlyGk(ny3PXX48WwxNxubcocrxBIRo&73BZVrzZ3qhq5fH1w4OxJI@}7#AF!)a zLAaOP+*Y`N@(nXjAgk|u9Dw#&)a;|6#bchn6dqD!8zZ3ldM6&we6iY)IW?|Pq>{7k z?Q0K=gg`_bY3iq3bH+_5+Q*GzC$#vj#mqf9%%S==A}7yLrcLg+{H-!=<9ZLur1oXJ zd;KNqoOlBF~y8L^wnt6>N%4FkP)Ji z!+f!~@Sp)P=(R*8NqNsb_h4}oWE~)yiw=K9U^lCP$khZ;2n1tEfZxd;@&FkX5V$N90}-U3 zsDWVpxpw_$3^|0~#^I-^yp|MFMDOGh(pdy4`<(VWH{WUJb@E-$*=M@;dj9qP=J%l4 zub$CX_Lb{HUe|1A(?2L`+zWu6b$3&kOZ>0)*1117P-sh4Hz!h?eJ@UvS<+IN|3wvV)n`79RHyCz%96(3~fn;#-0&>UA zlkIH)KFGtLh@U-)h*DKaG!(UiB1KKESbpk8PdfS*kDudiQ)xmc$0->*hwP`Xq5jMf zB26iwh*7s{X=mr6qB*6XB-+Sv0}v2=)YXm)#7{?tmJ1#kqi4CXMU%Dbwv1u6 zahv3kBj)MYX&dpq#T68Gl*!r+nry5ab&ZLMn1TG~%>r7t@0xzANxQAYRA@sYJ}&`y z?!T*QRh-r#u+4aYRXQ*(hB4T@e4anI;r=;>I-w{^mH;#)0iOZXXCJs#V7C*1fH7D! zE13KDaR32Rrjo19@ZLF&Nuq7lik!US!}c4$UTMMeOab(yeLFXsk`I;qQEU5}N6)p* z5xq8I(QNC3s4~2__|uKu%@eTjDw5pyCTMYQn)_7+4q037+SfdQk$UZnbqKHx&BIZR zouL+pv#NsvAOVDDMceX%(%rKGFl2g;z1SH6*ik`wRYVL;3R*Y-G#QF?zuKO;=J|G) zN9TRDBciuMwB=r4Bz>EvS~E2n`YTl5Hnxea^vPCzt7_dp2)@++Yg;#C`^xh#FtQ)9 zu7AvbB})MlW-7}+@<;wi_2CbH7-eTQ2*9o2|LKNU^2mOLgZdBh)U)>AH;qHF6m@%1rJr>VIU zlu;?D#=q$~iuOnL-IP4y^KlOOmw!N=~+OVBsX{8U5AJaF?r-d z;#2e+{!L$u90}3M@AQ{S+Q#GR%oS3?sN3qs5VlB*M3(+|g7RUAi`FCvo_mq3OMF!y z_t(0AeZs7I#udfH$gM5-pX-)&#qBNkn3An&P}6je_;u*JZsFG?_uWVUH*7$dWeBbJ zj~+Q+@=9=sAOI__4gkLQJ5gE8cuG3!kG>-{$IYZro~sbNB)BSbJnA?(7q@9I5Z`gr z*@-!F2U$>)etrHWYLQ5a?jotazlQJKVyEW%r37Fq!Z~8^Y5-h%>ZhJ%yfI`3a zJYj7@dF|S@3Lb@-ETB81Vy_eIY88+d$9!luYyfZr1-M*522ZFU_qXvuHG)lb)v@YF zw$^dGaN48F9KjG{Wk^6fIAkX%849&G*I@Jc`zrvY@vW}H5x*4(@Uq6xdOj6`zM?V7 zBDiuKAzD);p0AA^jI?tVjrT8Jrxt`!i%|%X8*5a9+niVu1Z#wKqMV6~j2O=;SW}0@aIt>WDTbOBe7dipa8^mAQKiCs2;df8m zc;ou$+PNFdNW%C#g8u*D!Grq#`|sCaE`{MYOoMI#*wt|WJ{J&Nvj&5KVrorD3GD*G zYAmBA;;E6Lh33m*4aYUdE>$Tp@)t-cs0%1?waq7_Mx!z3TK6T#OO_k=#4jWQvyxVqN`?6|V_@%0v=>kw-MwPCFq~Xphhfr!x+AkJGU`&5*!RXKDRM*iJ zDk3WKyW9ummT1&4$-I&jKGfV(IT}&z?Lom42qFICt;ZEs|2C|jG=56fud}yCK) zNTa5K5&>bIDL;&9su&ZkVi<<#rP=O%aci(-qwY6pZce~wg zcp(D%QYw)i6U0g?VDl#b>aGZVsK#qn6ONdpl?Y!VJ&B~!cQyB#MlYqdB^z~vh&9o$ z))Q0SRd^gWTRQ|?PU_N*D&YKhpCCOb<2@sfI<39R?SmLEgXsN>TmMp`ZGOHrva_Gz zn+OC06vp+_2KtOIe}UBgM=gOUc@Bj(+@R{&h*w=yE_h5)j|sr&x(I;lz@OX_0ORhN zDMS+B^-=+m^P$f`dlt|}OVK7~ZQ^U=-)*j-ZT5&(*a@*Hrj>ylTW;UEho&_Ey32iw z_#M+@B>;^aF*Y_K=>HXMU;N^i5ctK<&6pX<(Qr6K09eV0zxvE)K4V^ONC9v?zg2?3 z0J}OFod1d&gJRA20hg^URBIxe3@%RBVL7D6Q_>?a=mhFmAt@?Cc5ulLebG@AVW%wmk=Kx9P1gs6s73KKDb$H9nC|DvuWFO z&+B@=@feg63wf2QkLz$vEtv)43wO{m80N&km z8tm8=bSG+{PK!5EQ1waHCE9(Wtn{l}&Lza5n(S;oxwbdq0y+((dnhD;pzeWgf5Az- zl|cUR)*5dIFEAs26($ArSymg5p#FjX|Gs_uDpvheANarr(6v!B`v_pVgJKt%N!tpt;m<%Afa4q=yj6|Cp2qV@=QAvcU1*e=MVI3pj1nH|}ob(k;QLaRu z=+4x-b!iHWibvH*Pqw#V?p^bs`%j4=0})WR&m#0`qfq6#{j7Yu!iC-!?!{ID(L^;` zNfHhn=0z$-?OLptNT4m1u1V}pB8B+tnn#%_U_-F26QF_@NI4u>y>MC9Tr__cy`;Jq zQec#|yXQ1`JzYUnH;-_2NC6@@L?ZDVQIHIQZn@rmw&A{_KfPW;42pIHb8y;57036b z=q%O;d9IzMo+a?5^YSu;0MXx*VC(VE^4bf(a)zJB0qgz8tWKV=EfLuS^o0u-4CfC= z6CVR`t9SqbcC!%}#8~@!006jcG3EA8b%99WW`t_Y?RW++h6D{Oi`YDJ@?51w{3`0h zbM0Di^%jBEs;7uQ6F5HQxoYGmSX>9lyVLD0n?!FtegW~>Us&Pe-5NPMP8jXb#&)EcMm1OY%z5DMX+6X$<7dZ=lNxrDCqyUubm&Q zox2A8KWh(=0kquQ+$>*u>7_7)cQjE_yuJlsSJwo_KQPQ7K;y|LpEPj*8yg!H-$3I9 z)a&JwmW2)yuF|)V9A|O~*;0eGr}NW!ESqlXUNOGOp_~ z>LdOX5*a0YtUDKov%9ONZ|Lv`SM9ygRst}tpdR^yC^%%%TXMNLvD(msn20L>F z-B~epCAnP>Q?r2Pj!(m5%^RG{3aUCwtp(zJH;8!_Au*(AK+&{6G+ZUgC zo|})@W(fDs@#V{x5d~}r0Qy0y8GIrIw4K)c^Lo8u*O!C@_#EBtxI+ETe%}Z3h-;$j zDIlaKqSDbjBk^x+#B|?;4x%B zsDRw`BwCE|^eKnjqz(kLE}x_NLN^-RJMHnB2$o};Mma|0a1E9DX;fs3klcE_^q5dI zIsHItBg(8rzhe7aReGj6n6k;>${R0P`-$OdEkZCEIfJ242k#NfMcQ7FA{r6F$Wf0% zFh~qHH9^`Ax%#;fo_Js(1q54zS$GxJORYNnokBe)6J;FiH-+W?-JIr;cC3&7PNao) z0*Zad{^LG$S6Icl?|?!0IxL-~c7L!u&mclqwbFCNdrk z>ULBUBgtXo7~%j3u>ke(VXd_U1K_cTo6SDOcgkEZ=nACwH-P=s#j(9m2hUJ1x`$ovQX4sTkq28fat^yxF$Uzrz*OR{5%tuMV~ZUF*tk z!sA7kA6LsBw49#VdfH?QA#?iWro!XGiyIN^JZ2w-9?|yr>R+HSRprbh z0qnqC6#Ld$Sc>-3XZ&p5bZtGO{Ig0{{bTJl&ehi#{B>OgOxgTD8|R+YrN*`2%)!K^W-&@!gk%gdy0ag3wIS5eHCcE%L=Eoxg=S z#;Il@o+EI_V1 zYDbhzI5T&mtn`#hL=8NYJC=^)&IdY^Tn}adTs`x!wEfh-r^R>vc@`*a?PH@BD6Ji! zbQT#=(ufMvAJPwu-DgW)9o4Kn*6cb*@qh+^TsMiR<)%?` zqo*PHX?U(!aN{LY0AijJ7#9_`$#n}tY072x%%`biZAxy=M?jGSh=jhiv)l_3K!{>C zY9Mg`PGwbX-?;GKe&~}t)@E6jTX6qeU0oetzkXdGJb18z`UicB3ShS!5!meoVT@jm z{|c0ZL@J2QO{kG-z8BfJ9y(5P_m!xQS?^db5aTPH=jCGba!Pr@onr z()4nOHr^4ap5d@{DPt7ckp52bn7z&#ee+SsMSsEkwSn2U9oa+_?aW?K1hp&vI^KZq zY&yQ?1F=2J+=t2b+DkwGH~#hD`q}f$EMDVt!v`OH5D~!o(4j*b_ygQOm`mG#99@2^ zz^+yS0YIG*r~wra#5U;Y;06jWAY;WBm^Qt25rdc@^typ+s5KJxW5jV&25Cv;N8&eS zkQ`C|)s*8#QU>LbY%~tV$HO2Q#GOOrVNiceHOG=`Py0v-nD0-Eb?Fvjms^upPi2LH zs2}By`CvVZ{0l}ba9OQ3&NC7Ce^K?yZ%{GXur?oEs|*YxS?bux-?0lEQZCj(&xb&F z9ueHxaS#~*-bqD&)wxbK$e{R4AJSO}%@PY^^zE-qH+r}AfI01OALiZzw-gfuW|I3= zwuEF)+4>Gt1$7=Iv0}14+B*I5Ctv)jKl5XQi_g5s4X#51V7>p~l~-Odc7Ugzda7gt zaOKKBdI{i`)Bgi@wHv4hxM?9{AHJg?3{pYB3kazoBnuz8U^?v6;OZ$xV9H|}-F)W* zf>H@#&@rN_`&U_&OB<)}r4XVfGFD<7s+SleK%H5O=p(gl%+1?mkhI^2bljY@A+Jrp ztIRk7;VX)m+K8TsJ+H^tB=a`ezO48Uk01~MHCm(X&;K~B{o-G<)xv_&&;}2|3#fA0 zBTMA}86gST*hQa~GYNs}%I`+M=z|MVBme*7Q) z@_6myIiA}qj4!UU`e%cg#Q}extgWq8uqncx2noOkfZeLFtKC3Tc=+LmHS6ybBmnaG zLDet5RPE%X;97;CsA(0g+vMj{H^B-eIP03uuC8=#8=|vDo6;fS*2q1zPSwg$j zfK(s?9(g!Ju=ZWSP;8|Fq=KGoZkbOYqAWqU3FK$gRNb8++Av&yk@|;EvgESd;+m;j zKAY{}b<LykhnSD)NYbChn$Ck;g-%ZqVs9kwI_s_N$w)BrTFB;^}?JqLt58eOT z(W6JVQ3IG6$dbAK32Xq2Q$m~(B7kYuroBP1>umrq(m+&$$OeE4$Y*RN^Pbl)UwsEv zqF>g5gKcuC?n;Tg($BQ%w8um@x%Kq9%3d#!U+yuKx_F5ceI#p0VWCs5JmZJ&36*Lh z@CM`ViJfW^mo}A?Jg=1KEVr)Ak9d6)_S~#g=X!9eAkm4J^lX5bHPDvVUNDHla*kCn z9(ERrAi)_z(p#E}2@IzyB!p_DSv@g3fm{mUo^Qq;V6H`w82^C?bocFNg0Lba0^KrR z$8h{;?~#8t<-|PWBV9;m=TeL*em|T<9R92#^PQfJNeH_Bg--o2_!t1t^x=(btQNk( z&%$+P5;s;>RyL6O;l_;{F#e91i7d~aJ&OYHr~qc7>Rhr(eJkq!0lV1^)F652=$x@e zvG_(``qGywwgD(a@}V8K+2D^=yzwQoX?WRfX3oMs9Dz%&If#f$4Xh>^?$195?oh1e zAnz%Vn=dsGSc97uZJQikr-)Pb$k891CMCq<*ZY!o91dstX`C}_6LXV? zDc*}{Zmxl3u2s5RRnJR13ZV*nLD&ML4pK%Es5URz7pfA5;Hu6kV>o^wnBWPdgA_0g z5Ybu=)=jE{oDmgK$hitvehiJm^+`o8W5-}AcbZP#BX5l&I7|p?iC{YzxouxH$1ks) zh5RvZqxwpU;wT@RpzII30Epf1wzfw z9z(cV@+p-}oJ>ytdG^++e{dggKWYXJI?iO97?N{o{kbeCmf=&Gl2luJ;1+R(f~=9Y22DNCw;dVDe|h;vUQ5wBhk~C350~ z+N!GUSyfs6xl^I7I~`}}%y7Wrd6w+I=s*yyT53@>+nk$~Y79dek)YAu zt~VnkM94WtsFEvBzpDlnh`|$NrNk0bVni`v_5@gp7paP=mLzJo(0Y9Z4PXA8BcK-f zdr6>+oHDwO4)#*OPFTXCw`|lISt`u)==7!t^H5W@MA6Pg2nVX4Po>Zsns1|cJiy@)yf80z3KsCrbY-iVN%QwL@6d!3Fr91tpp&?@=u zRTVMO$-PT8;Yl%Tt^dfWawG?yTYXH>B*NDXl};i6P>RuG;z7xJGZchmdmXw=1P_ub zyHq8lDUrll7TRXlfC@;DpfOyqMdKY30oMw*!fI5JS0{BB_A|%2Ng=^hJI=FX7RXlF zH&Q@OZ38z>8ci!Pi9j!%1bT^OI6`0y&uv~eFf4a#lrdw1nj+?VY*lltHFxTzfJoOJ&pfC+{54rS1l89!Rh#KGI(u)AtfaKy27O%z>mq*7tCEX>7mQYm5QvW ze{i9e0tjk`gJQLs<`Dw^-!)X;u@w$WF0+fzrI zn2UM1e~0A#c0&TNoWK3+?dzL+YCW^;Jv29aG8)`?>SL!F`J?3H1gf8xUw*lQ;rCtd zdRO(-Q%{*LJwxg1jo-@1AF!7Th)f_DKJ@9+r^!VF+YmrPdXnMu-JVSprBTz=;T`iL zRcW8*--LVBf-_cz<8#ko)K*C=W%ik=5p5fXp2s3uaJ3Q(=jQJFugCFoOVw49E4OoL zd(KsIou(Lp*TgAS_b4%UHy<4voa6QC#<=nNRLd!yIT%q3YJ+fAQ16lnKV z+l~RL9>A$*jlOeu3l-fK=>Wy%)f~EiiTpkHUwHcrnS0aQ#|B_%etm6c^v&%5c=O`R z++x67{{SwZa1=Ro=upYOE7%d)@N2rocW?Q<6;nT?uS=^{P1z zB7lluo=cp700e}cK5T92Y*ka=9oWs3OHMi4ntN?>+(b3PNJrLnKjAuYZI<({ z`wv{V7*p!IZP4)Wq>?EM9BiuC(mA7W zF@-xX2eXaE0I%bpY;Uy;&9;iArR30bqi<+8#rO*kthPGGS#bN@TRKAad9OmQpM?X9 zLH*;4z_3Y}*1!C#PxD-dQ2Vd}7=d2pwQJWZxP2m#Bmfx&M2+z|?&cd0yV?c-@NS?l zyzm0O`OR3i9o(F1##MRGdld-Eg{`%VZT(qXfaUtmhn}Q?|nK`N) zgIOoeQjO$FF@nz794Ju*1%W5C*7h}Niin?^WGeZ{zL-C!^4e)qlPz9)-xi5SHW8?Z zI)%v#G~R?1Y5OZ!0v|oe@D{3@%4Ag1?+m*e8uME}p$M-TKII z?EvWT;kKE&k2c&nfjIjUUUM$%r>%4{uE6?&}qX7e}h{xO8+ts6wKFR`DiMryI9L3Hndf9GS z*wukx(9`))ZP);wc;X2J^i+KFOmg(m$XM;la#DWDKckZgjA#xqm?TxcH$|$I6V=Ig zhZs`?7IE1L*B+V)0ukCuge8y5%hZfxDYBQ+K=ayo%oO<&Mb_2awYqK}t92w|&yC%( zFVXq%Cu$X8|ITA7a2nhonYi&8an_P*rK zu?Juuc7pufE$povBhs;F*3ywBV`I@KA5ae0!(Z5x$E0>tIZT7Yy7%p-=WX=8cQ!!ok}3 zsU4bXr!)1GOn}+G#_@TK!FAgOM#d}DW?0PyY1QL1As7s%pKps^HmabNxg&ksjK8YC zY6G>Vfo^^`$>|mkM8;ptw|{8CN^Pa9xqv<9d%Jbvcb?(RztOOp;{Z$`82-H8?xmMrDp1VXS?;|dcrqBSea`!ymq_^KUQ(!lAokaW z+Zw^lL@Hq-_8q9$RB zZ3Q*Q9!t|IIqxq|2uyZ9xw%lLzm(V#<%n6lSA{|PNDw!^_>@xvX~X#&gfg~Lz~Ws? zb%AlmfiCPjB?xxi|1=_C6UV8n=Zosir`a`z-=$T`8Y)dx?M>7bkX;x8(lknAyzb)a z+=MD;{8har^7(#dL!G-T*j|L;7x!cF2&%6YG53#{0G{imU-{urGhWzYguk`1u`z-A z2Lyms$MF6^=uue|!@aq<}ML&S*#ghYufy8px}HCRZ=N^qbs# z(`5r=WAMl_??l%7z(Why-fpOj!J#`#U~*DH^yiMN=D7w<&?!r)Xu1kTB3t=MR7(yy zgxc2UEaYsDOJj2GeEz%ZKOz#krCqL1IbM8yLo<{RipX`>S+5PJI^L$ymp>lz0+l8q znNpDkgHc!|nLp<<2D2(=dC9EB7H5y!PqsGcwE##7 z5{zBPB#0|a`iNPOGyY=D1sk%FmSQSM)sOq#T|8u@E-j0^fB+np+t|kE zKlkLzNcqg_|KZZo()iG!L!*1{xu<&j+uv@wh7$?hrm(AR0G?tyF%EfwVAZD^H*SCs zOyKW}01rM_4oBO+;JtUsu@!Cc8^)yi)lnIoqgvj@l%w_Eea~Jh5Pz)*PG#2>j%RiK z(~#_RYL4p^P49D_>!;3$rq5$uX`p3~RQvo4LL)L9y^qxZ(i)xnbz3vo)deuF zg{G8aMN7wo6yT81BBTJ-2?9_c0#i_n>CZ$Juf+cSYDQ!*B|iVkS1PmXgHg9lwX;*u zrVfI8Uj*_uzm4nZ4B!7mN-)XyoYXUw`wg1@YkR=U^?%l&Vh z{-xi7?jI_kef#ze8A>pT&p!KXh4eAV`xA!LQhd8T;O0^V-XY-g0YL(QE7!06+OHLC z+2?1jgEs+t(`6$bKL@JU>n;}k#g+dKw-+fPy4@wm^})9fo88dGif?FWNMHN zbNw3^sdwzHL<>i1BXeO1L~o@}gG<}71YuG@7mO@7>amn~3fhJ*Z2e%PPcKJQE4o$f zX_ElP=_c-&MtVU#;c`6HfO|6lgbGAo828g6nG27J97ip~K}xArD==8^H**DHsm7C* z0mX-4|NAS)4edl>4qqkb zrV$Au%Gw6zAw2b`%8t?2S9(63r>6OcTC+{_Hnq>0TIXE*e;JiU$EH(2;rLF+<_?ik z0Fce{sg(o~Av*Z5QBP3L2GD{awJ#8FN-nO}tC(U8hTCTrkQcu!UUME6yHkx7WedAN zT3KAmUzZqvi-momkJ-pyNKZ(;v2=w04hNS{uL6SV2dtjq`~?B%@Hp2$_miJw25^&c z01UtbXa!E4IyE_d{J4f@0BN7S`p4T5_l&@|A-KZu#uJERq-V~Bz?JVz+Y(E;U%g8%fB z3@QsH+sNO(U3^{LfTF*U|43Z#T`*PDjjrmYxY>L*ZTgFwcZ{6sl z-XjY0qTCt_cf9Z_AXcd`;-6f8hN>%18&47w5iAhQ0wW$@ctl1G_g9bGU@tB05bZd{ zdKp*wp@Q3#b~~l86fN2}tMsD$_zJ@9K$k3qCDIt{sR~H#1jZ{8d<& z55ClTs`@8I{)iLkFMGqUc81@Q`&X=vV~Jo~M)$^CHomcT;nV;0v%Ci6|KHf!+8VIo zcg%k!yno*Embaknqj}A|>nDuAx0wL2M*sjKC4_#@d)|Zkz}3;CM=NIsfIBE|{P^O9 zmwuk_@uv4%pG>xciAky0pcN4T^CMMyBphh;j!!etMpw1`o^~`LQmx>Lj83l85#Ot# z{*<^zUMC5b?K_8yeUB;{uO2I2gHGl`Vg}9Y$q~+)p2+Lt5#moRX4XEA`PU(9?+pYS zz~JeRP!}3SF9o333yi{yY*z1Oj@H)?c3wevC71T!>w=pSBG4)Yh%B|nC~Tj_)EI1i z{@Zo#m2}(e^i0IC>2HUGARzv3?CNO@!mY6Zi_N(@LS>DP#n6b+X$*$yXS&aS(z%PWH4=g8vu9I3r+R$Tu4^-vqMfM+(5KWEW?}6-{?K$wkIEtI0#o3wbefKB-{-v1ZM0Cv%?FSBiLcwjVb& z#!dA{JzaP=R8h8Suh`PVk?WX<#>=6L69Gs)#!Km}fE3be6 zRBRJ~8VFJV(!lc1t*e9W^?&De!((J~boqgd0N=?3=1`)*$i|8IsM|S|Gnvb0KYKex zitXp7k2U4Gn>cgxpkKupJD-nIPQ^^#R$vK0hjrUUHK5TK|AQ$fTGt6Ejq0If1hoHw z{YDz7k(8?$0HZKcMKp*2HI^5Q!C^v-{26&7e?O3__4&_3Yx1_&3DVdA35X5w#$W64 z6Qig$<8OrgdGC=H%FJBKF7V>U<2@t z_4V~FM*hPC2M!`?3BG_6UFj7ZGf}GZCm@2Y|ls$dMz)4#4^! zut?_bmGjU3XMS)_n%`7OEW)fLrJ^!&yTUG#B?iKQVY+!Q-L1aYj2K+39t9u727(j{NTD>T72Zq=0~= zl_LOF8rb~RA0#!rVgcR6)nM&#YYNLVh?b~Qe&_OYrt)~S%M4yN^}1Kc%GAtD#)aZ?&j514ep=PNZ$@E z9(u4g`s!MxwshB`NyA}0ejs^`?H;4#W2b=E&qqEvy7J6ReBUmyt?wFa0A*Ps@!OE` z0GK}vUiz|+-uvfvDEcxD_9OrcQ2!_|$H5K&9UqLrsQ81_uUlJN6T}060Jy)=&iAj>>^U^s zQ0sO6IL4E0<{2mIXUZN(n@gRqY1*DN=ii*KSyX+@cmoyG>GTW{p!Ksf`rZFOnPBtE zr5>JCAq~Ouv1M93ycioUwe<{IV0Dnz8Kba=j4{}QHwt?&KL6txbpu{!Fa}+L)NPuF z)%LbktOkpE+`~me7t%SE#2n$REb~%`g4zuV47(ANUk=`SoJeLd-iPg!V51L)eozHj1^nBf8MrI!0k)AUPI6ps|8T@O+k8y zVmKTsHUxE<2rOK^dUb)hf@KyH_whC6Z)xe^LwB8c=pS+WWm8n5+o#2a1HLCh!I-M_ ziM3v>7JSwTxdy41rdGr8tKoXTQrExQac1@RTK8}JtkZ3Kl?`f2PbOoNQhecS{zvNE z_Xk-sVGEAH@Y(5u`|TK4%WKbGBU6CR6_YWa$TYfj$@~S;ND1Zv-(m1wYAfxC44*e% z`^CNnVBBW$-n2-~<@UhYOYHA7*b7GA-qKNl_{-q&bCB&T*c+p6<3A+lv&r80;PB$* zFaP)l{}qpaj{nXw!oR@A-fK%sOY3a--DaXNfZ_MC#~!Pmc;X3DC&a6LeBEE#GXme< zpz8Fk=F|E2!Xv0c!3RhIkN}Vckhy~)|7{~V6#wzFa{cV)=CvOS_JGOI#0R)npAaEj zbi5?6GkV*4#WbeZFXSumNZy<`()8!EHG}Gg7PitqjSdK03wmvtp;!rJw01@B^V}z7| zPyz92sDiexT>SDc4!76;qgMk=*d8!pB2aT?9YX}1U_ni^KAkvB!ShKwWw}7t$bagV zPTG#E>@n3iuIA`-hZ>vL>`%?PPW3vCEi^$cM#QFwFdRA)0ah>2_HX}lDn?iB{VY8Z zm{?;jtNZsqwBI4KGO8qI(naKq!tO)2sDaEjnA8mIE}?G(nB4xIX%WiK`0G_a0{I)G z@4i!!tEW$|vC2kY)h$BIg%9`w@-Oswbnd6W?L29% zhwnc#fhO<2w-1JSTez($fTBG}LHX1Whz>@*VE!vuCs?pepm+ZK`5udfi~NwR@B;(2 z&psv!t9)$#$-Ce3=X(9c-=|%4aG$xFUca^jXu<=y<>CfBydKYwavn)L>s`6)^5>@a zwUe=S*0x>?&2e2@yWHCRJlf8+?W6oXbr>t|0hOYm*&A_6!pm8RbN|Xqc2iD7?qDv{aE4(X$q-1)}HZ_J2}5I2oi(q)})DW_JGORPto|fpESPcM&IoUlUJpU5m-6C6f%d_ z2zD0LAR{dye z^gVF@eyj5r!FK2etDIc>!s!bi|NdWw+6Q`n{)5-g8j$~hfeFF{$RFvRf&3Blg(Q*q z?p!~$4EDR-;m%maYY5&W$lYKhh%;&+{8(RKSIiA`_;;7T_t-$R$cTUO#*G`x{0Xn1 z75uRnxqA0~U-M`C{iScL>)du|ap|CW@x^FN7*JT-x#LaMjl33!G9AGGo;vW9>t7FO zn`7JW^je~?JYzO%ALVPNopG1%y+h=a@yK`wEx+e)5*>WhREvb?4^%+LJIEw-Q*`Zl zW+cYsyn z%wS-nc7@&_x%~~{6COXtt&{ii(4&XA9~+4mWWmuDM*#C*q9ZEAA z1*E%6QYk@1Vjv;{f`mwKG$Jh`A&m-xw3Ni?ZV-?h-HhH~_3ZcmAJ2>D&0g%-c3j7O zU*~!4{+{Rg`AR^JCUB^(zS07hcq|crSQ$iIrf$tqyGD*Xg>3XH1^(x-D!v*)MM~d; z?KMT~T1|ZP3R!{1sZsC{200fU`nmJ?jV46SSJ_vzX=MR(cr|p1t>wMafnxj=c8Z8m zIyzDh+`o#bXX%E-U_)W&O#SiXSKlK}%FpiRB&$UGYCTTFj{|O7C~N#|jIgaLnDb%> zr9GM5+L@Jq}1eSFQ+;}J!&SIgW_f{ZF+d_!eCT62&EP;xp~jbRdrwH`C3 zNN%_!8ID*+!n(-l`mcy{%>xOs@Z#8T1nEazB8{(n)}OySZvbHS%o$%oX|!sGN4zmL z@h;7~{cUYs^WN9kEP0C_?P#>e--+Rs!ROCs%YiJfU9G%J*8de?{cui)-SB);#031< zMHjVU@AAs46Mnka8_)CsSq;PNra_Gv+1l>~P@a?F4-aY>X_TKnmcg!ZJ7>C>br&twS!ZIg*e-cA^x#(FN@%Eh~{WeU$Rh3Gd6@ zZj|`q1MsMbVT?b8_wVZkv^tf=5C6rpM(6$J{aurRuX2!o5L?hsM@ds?i~5hSw;md% zDlkPH@kB<7yVaMG%G6ISx~bDyAD!Qp^i`vb(X9Y~$Evwtk~?9CY`=rR>5bwv?BzVF zGE4Iy0XW;sSrKxv3nm|w^IwrCl;t6v7uH%y(&0<=W1AOV^xt@2hglPV28x7?!2kVJVUth5g?YvSislwAZ#_{f~DCQ5R&K2 z55EHjRZDGvGy+d~?Ea7mo96#g$LyP?WN#`gYmgHdV>22*bXH>;E0A(j@BPh@Zk^1a zMdDdH%p~1@X#x0o*ro!w1qRT5*qd zrWW$>ZCB%db=K&2q*pUWDNXR`juHl%z1v$}M~|Yv^NytjL#Gxxd6Yhnu;%R%p4#hv z8+vxc1i5i-NlzF(F4#3q-;Kk;@|(iYNFQ&kAV?#kgp42J7l55DxP6i#c8Tpkw9{D- zthh<8aV@SCwlTvXn{-#2<{DWvoq0WRbSx6>mll~6o!yIz&r^Ux&YZ7`2}(|-HQtP% zxv*XdqM~sp>cJaDP>ZsSvfZ*@)j@H$RH8d1u|1_%G{H=*40QXd^LF1#$m|zqo0N^F z-=vM5q#+EQ96igXs_%WlI~Fy^3(-*55}?xvUf4oPw08xl`ji8;gLkiHfH#{O@(!&8 ze9Ga`_2o5@Lj1WKWvLrY7Ge?|5 z7Tu>)LiwUMd4T|f7li1INW%}~05!F>gz*HP#$Hc~vt$FLoO({PS)i9&xX}5txDL0V ztI0S`@*{%pKSdoWBi?0}E!qBl@lK6tH+xnJ;=eBVPkTPsfz-Kiy$>1u*H@g&6PzVi z#kn?NBj}eur$5-4<5tVGSeGtSO#%44Vcn@`!YnEwBx|n2u6B|B+jrj95J78`98jD! zt8zul6cI(oUFb2fLciRWCp!J`vw^AlzYTE|b3XOM#=E{g2^83vh7>~gsOk&?p;$x!=Q&whvMC~uIQylK9;N0u&jJA1WnO?ea!tV3 zO#^r+PKoDqW+frwc+Us6Rst-N-2h#d>hD z7AeOZ%c210E|r2_@V2u*9PcqOJrs`XEv-M8^?i5}Y#%b&?EJX+`+Y>m!Aq%fsg9hw zx|=>CwuVPj$}>v+yFKjzed{BPRYjwO2*k`e9HnAePVzE_)~hJSm|LMu>Ia8Jl?>lI z3j)jg{msAJypKsl?zT3>C0S_QX5nd=m7kcaT3--Pe``wdN#{y*+owOo;LDMso_8d+ zu0iUJ%s$xu6()Dy)hJ^q8iCzRk_f#FKw6RD+4yO=ZG0`PvVP1fFAW#IHYP<49c_LL z+b5m?Dh4C@B#PAsxk?e4HM=CTWP>?`SSNz>fgYlHGkKwG11G$#b_l#ZpnDK9xFJB) zOZvuJyZ2NGXdAc0ragA!$CVbhJhpQsDiMZ0+F!#~a2#4g@Wpo5$6WJ;7jdGn@YpDh z-|b=9jg8_Lh(lR!Pu4B7x^hhw>&_s0|KC6Ma%mrYR#cdGu0HHgJFh@ZMt+-h{^t6^ z@)rr?<7$NBubnKb*??vF=hbxv4(8FmeaPpDr9FEz+Mkq!CU#utYa72%2Rry)klJtF z0Z@1cMv+3#CXlUEjG{4BA$E0Yg%cLtpQa+_uZUr{&n7n_$dL86Df4;Iz)$w;LpR!?>1i5{rX;_K>HC0}rs!T%)(raQ zSl5EExmjAgfBUD^<>ha9_k=@zU>LJ_i6pDXhn&#RjgNSMGH{lady`3yG>a9ld7w8W z&RRe={{ptKVJP@5@yd92$>A9QXeQ~_eQF?X9ZzAYn z%26iceI9Pn9n4p|t8ws%)F#$9WOw&RUlXfkWA$hBz5BfP>?@)ZB|AST?e?s6QqzF!VlK~A^q~$LzNR{C$nM$xhLz;bxr32z)ZCLfidA6|ivwHvQCk2oj7VMnF znTkIHAUwOqV|kZm4)Ioc0NMxNVZG0t!fO8w8R+3A%wn>86K0= z-3C^S{J&8BRVt=zcp0z5P0=^}rQhiF-L3tItx*@Mptj=!yO5k|uhm)sYjv6&zhPk7 zuCL$4n(vL#S3lzvg)REH+%b`rp=lmyd^|&$Wx;jW%o=ZlZ}k~YyWH8C%G-x1r!P_z z)b`3sbXAmmmxZ3^>9qG|ZrZ&^yQ7aeOnC%dzfXT}moNOPp$hzq2lePVW-bOXnByUU zyYiG^?A%4^H=RUutMN)99O0Hf*0AZXhlODI@q?1Vw84<(&~ubu_F4%I7$I+xui6rJ zvfT3Nua@%{Qls6IebJDIa$WFm25UC>!lW0`%T!hZT>?P3|A$kKrzVV&Aqle6V!`jT zP-YTPv~OIzCS6sDBxBU3>x-#P@GUk)LaSMzW3zsjJi-U#vqsg+wV=~4IG9MR-7~-( z85fEsgy@SAwK#hsloPC08WeZzWkXfgs^9EZ&vJ!EST-FTTbeieEog zDWfNQ1kf~-d`F>EGwCyMa58ToGI^G15WWM?+kPnXbjrrjKSN@_jK=3v>{`a=+@5F} zRO*&*Iun6foAh-OEX5vEpqbPJ3IWaS%sZ??vh6cL~ zyH-Fh(45TRDy4+|xC)kqb^mcl^7Vv|LJSmYy9VZOn6w@smo&qvrp;hYRPX=!7QFVT z@|pK+@n~GWvOQ55M|m`1-p=nfzD|(m08`K7ez1CsU_6C4vo+>RamN!bOIp$T&^Cze zhGPGh`SpcH4fswiMNffBli0BG{FF%&}(MZ*N*Rii6j`Clp|Xq14|>+sp%HefIf`4l3V0x;9HE z8{jp2;xcNgsg$S&p0| zz?x&|@Pg+7mqU$5j%=C+Ir>%xc*D$(+YM~jyXpxEPGS>1YWaDX(0yXCjonH&eTqKG z2$V?Mo|)J(xpxAtuW==AHayks1UIai8}zrPN%6#g?v9F$l%7=T*yMtTLw(4&brKzo zz>+GihoQdq@Yd7_p?3CD#5h{^Xw06=3GE+ki zMBW2WDx-E^k0GK*zU&eUi?!I!zlF_8$#JJotNTvJedT|=abRV0ziUj?fbd+txe>NS z2~|I;-m^Z5&jv1@VY7n-r;Qdwar?KF(~&A)##MeJ4zCzLz}MgraMOH*#n-PTz82hb zwvrY-{@(ZdF9-%Ad@(l#GsXizmEX7rYiA*%nNN%=C~nxq)*5uvwG8q4l}#t}M{D~- z-~^<-k3Uh7<+Y?TTsoW*E@(aeNWZldGq7CfckEw`-M?l%TN_jGnj#6ZsuJqfWqxP< zJJ?sLImF%iFV&RKhul^4G*dy?4HNEkrp?tl2=1^xAqqpMeNIbLXN@cp-EgQAx!VtV zvf1N$^Ty@jy2w$1*BP=`JuqKI0aFakqDHj$Lh<=@o4 z+lNUx#GcKS*&HDKFC7bVX=XnO){9K>=C7L!Mfz`$%<~=r7g};`1K#xe$jeB*=<5tLx8>S}P7J3r{#KBU0ONAO_p!&N(p7Vhz@`2TjVZp9X zTfX4IN_nRtucdFbjRM3A8Y10U${_?HjGZ3Dp0}PCnrfGmI;li>o+{L|K;5(1GLTvg zbyVM1q!UN!EgVWM5!6L@lqCq@@uQDs(3s=_)Nl~Ad^M#fF(6!~B?sts>GJ6a=$Y_& z=Vlm7ljb#MJ()}+)~fyZak(MYsFN)&Xp^%a#!}L)bTYE5Lh0cTrRmm#JTx!O(FG}{ z9HM>p23s^nTYbYkMj4I^iS=m(eV64^s_lTM--yn87wyq`1}$Z0HPu=5=_1OZv;gP;HsC(S@sYDwnS>T!U2)2sX0Mgrb13Ks&~Y=kb^?kHi}{`J80%H zgRy?lXiuZW_K{3{6WurVZvl$Yktd1il@+erv)bdEaY-G!;FD^sYX?>YKJm~S8ye{8 z;o*^%Rxf)6CK+=Bs;H$iu3{`T8sdy3IZ@(2m(0u-JaNK}D7dFJUl_IJLWZ@PB2crz$B za8_LV+Qh3P_F@-KBFG62uEd>u=lDqBWfZu*@Z($Qo@jXbaIyMwC^|oD&5Q2dF>%P& z_0t6G=mNNU0r^2LbErkzp~tfgKN;%3AaS|=U0aBNYLxkxWSz?3#GrlX(bjZrQ^$V( zTMPse^4I2{>y>{@d~^eei?a8%)F%SpxCH|zme;R~qi)z@?3e!46=qcH9g4}>Df|>T z_}g#0^g|>??VE?1MQM|EUX0b;74WySa+Kzjc`~h)_+K42(cN0gJ(v#4=F|P+^DOQg z7I$e?)RVP~w+a%fSE}&8BW=J_WXpI+U{d_i-NcTLA8=Roi|mhUO~Zaz>FN2_&=bA? zv|^&u#i*blrWf?rY;NE4JUh{PCcNJK-W*~0mT8|d$Jz{4DaWBsdQz4sM=a7wkMWQ8 zw=&6PsSkh+y3?;|F}|FxYElE+-+n%zZ|na=)(rB?dtG5)pvUfbGVER3@s^W}r;v0Y z^j9>l*XQ@TdUxO+arDTT^}6a#Vy(TSNme?UhR}EkNC9-bguK|lwKEaQrSmJkeAVJH zNp`&mXCsQqg5AX`a|U4ULmhs13D2$1-RZxYhc$Cfq--J{pRlO=<1=y@X$sX=??1ICUM*^4BhQT5+8kw{YVFb}1q^-bCd=2BMd74l1oq0)Tz4 zmvt%0#y#=24XFX{WQ#1 z;(@;ohBHL}S6Ly0u+ zd2=30DReSUMWzMpr^(-jKOn`Rl#uPGyFQ$~0L+Wy6LnTA=835z-f<_jX4-j9mG@$0 zuu0*?jW&V1uM;%8@Cu6#h9w_Zx)bTcaUp#_p#>6{?&eitBQ5L0XX}c!h2O%lOQziD zMU5M>;rNT&bq7kGf91}_u5`;#qp!P7Zp}(k-SndzDb6ckEtCmwZr{%^ zBZfT}Rj~5CJ?uZKf?NH9jSxRW-P`UJChn)s>a=+pOYOpfYoY}^UAnh_{&XZE-r?^a z;Pf^!g;OsusAv1G-iuxuB33eoY{s&7wF|(^-|M3cddCX0;Iv^ov5$(f8YrC0b8`uk0S?9v_?M$0wXk2P-o)omv`VF!)+jq2R5 zTxk1yb<8XH*;3O*0XPhdU7Z=2=tFGoM%Vf*L&&7BQJG6RP-sWNyA>?c=0$sqCGk&* zWn1-X1~YQ)Bt?2X?=Xi6z6#9K8x$c}KKZ=fM2kE|44s3uF$cL8DWp43B0e_GCNge)^-liVvWf_Hj(Bbk*#e;<8y@%^MTV>=r%x2 zV43NN{$2M-*sxOY1kY&L#Z3VwDHF1f(x80{1amv=goQ(K{@yt6JFi}9Xb#_b_1frL zh^C9gOJuK^;kWASb+4zGz=frVYHC>vgTU|{@@N81%Fc>7P3qHl?Mp@4NLbW}-2nx^ zpr$8)gNTXDPi@*PFy*ektn4`IY;wHmY+!QkX94vGY@$FH)bM(@|I9(ES^jsI61u-( zPd|`^Ej3?zpL0tpadVC(Nn)-D!`aJTBm6vhdt%4~bLEvcr8kAS$& zJgarf*S_Fol_V0;pH1ToEHr8zr59Ns5|iRl`6f(Gh?>x3K5LHc2UN znXU)xSHwiH6}9h+1L9r3oJ@%d@yY)VvGBP<;urOdXh2!GozyMSALCsu-SNO*XZM$M zjJ(s5^fCmflL>hqY65e>WKt2&jy^iwli*YN8vO<8L$fIJNp4N1e?m{A1A^O6Aw)Ot zU+tqYwaa!c&=kN*BMEc;Q5ogd}C;!MBE_46QY!WW$bD!=|{-?wNc zmyq_WN!)W}jmhRvA2+}W91)eSA_CT$&@fasx#hK%=z&w+hd_{9v+C* z60zz${CflvNElRIz;kC$NlNGVC5_?lrDFG)K%lEbr*70 zjZ;0py?8Eh+ImUJj~YotzpW9di8*ThbJ;VXp-w2{Fd4Eoob zg=g;{IVq$%=ysSjWYVKuY1=M&x+=c-Lfe~Cgr&t__g}96wGfYF+|1!o2o1)$->*cf z`)%G}eFvtm8C8qY!%D-xhczbseLr3;*iUVuSb2u$u2f^7pNK`vV+A6x~hVAJ9 zwK)wl|7wRdl8H$Y*}d2xu;!Czu9ze7mo-Sc86*%|`^GZ!i>3cQV+zNAE zFaG)M8P#vW=cvaVtqHa`QhpkOQLF-$Lv$^rcBXAlriW_<55(W|ducRBYim!w2QPuJ z|AZw%&*3;}#5)R7&=ykblY*}P#d8H4&!(}1dWMRL#G?3=$BZDz=1GeyatyDq1x9u0 zGnEeHxew~HLIwgtNwXJt$-wozmOL%99n((uppg+Uu9WUCZF7A3@+R6w59<$gUgEOD`PzvYHJ^w55pc zn^2&1msmFa(?@_uUreiS&$=A=@K@;E6yJTh%m-6@hYqM_KaUj+SY{%; zBNJ|`YPpS&77x{__s73;Rd&+;F$q#I7xz!G@GBbt^tgKUOlONusynx1;ye5M`+0lG zC3zM5qt{>K21^i}xOT|)*O0ii&;s*n`oDG^>7=La^q;z3hdt`KVn}LAbuMGP4qr|N zuzP6nfXi}%jk)x0!=5j!@dJtJi@^>0*edeG_g>}c4M~0)C>F4L;MKqS(6ph$iCmnE z9@;vK{ufZm_aze+OMdBlP9XdK4+U@r5pi2q=b~f5{p*x5nVX-#-g>FVZD1I>Z zoO6RpS8TqC<4FSC3EVsf+O6&?r93Jh@EK+r($q!gOvUZe76s`005uxvs6U0=M|{jh ze6SQPv|&eQ(w~qPoeqSZF|cHp&!~}g>cMh}ABD5?hdv1fV2{p5J0ZImAK%w&;)wh!>A_rHVG){rAdr_egmr0uzPKPA#GAR$l zCD?jFw4yoGLb5&uu;r1o&1EjujVWH+-yLg3Cqx!%@2E?w?Ny%C8XvNsq2N{zcsmzb zgRav}?J-xlW0{gGD(@JgT}avz=+!tbcI2$S58yR;Simzz+_p|H;{)rAaHqS0b6yLC zFOggLr^&LU)(9dM3E~2`)UM6-*KZR8KIs=~u^STo3U&PI^#l~JluG^1Xuy)M$RXI% ztMY+g#(a#&bVJ1#&6?#!nSx3`gSoq7w)83Qj@&S@Ap-F~Gr6A$zMPF%Rbw3@X5f#w z=Ok|@!AnKT6cHv#>-SYBQMQONvSP2OuOwwT?DDV!Zcin=dQ)@NUb;9kU1&k z_i@cuU=HEMN8g{2xY_duSMNwY&r`fxm)k0u*M)}#?Jtj`|S&MZiAlfMeZJIi)*A9Xz-@{I0{DpO&Yj<2ya>l6xq zM#vhw&eiK)B={}SvA1Qo7evYhaLQHQ727R%ecoW@2@qyml=uRUHs&P zdIp{ZI~A0x550;{X0V%Q(0623RiLj!exA;omlJAX$Jr3X>`M%vI~PwDRf9zMW%U{7 zTgke$=tVMjaowBqPo#JQQeZvhFWomJ)zE0+97_yrrh*=kihHxLRHU{LWy=|j5$;V2 zr`O~HWa_iVu~G0r%vcTzkB^U6@hx%YkGn&?i;yc0Wkj@|~E=RtaVf)H! z+OWU&`p}b*wN(}5X?!un@nM{!c*2_BZ8~$yZE+U&R0v7#dBJNCP5<^>jOe@uZWCIM z?S_PgNVt&2&O4@cWn$~7ZaL(=AgMnwvF&ryY)jI-*KHzR8U=zNkOIWJBUYmbGyM2=OcAN*Z z9_W2`A%Q2$F5hrWU}iI~uK&tey6Fh2`jQuD%x6>v$0dS)7_D|ikqwmX#rz(>m%rtY zeRDL&eKW$T;jjz8c1uAOFkQEWUpvxy$()jp`?ZVH(|-y#ln%uAUs5pcN-;i6ivRRI zLW(I$igH9snhQUyztp2%L(12=mdUnXrOmexe8y7!HXj?qf($f$!0GT00aM7cQ$+8j zVX=*;|^V`EQXA3D<&M4lo8n%B^49)yFxlA zZwH-uA}8j-YBwkm%sprcNzLOx9!f@E#;jj2w<;a2@)<{fU77BD!Ut5V!j3 zWg?~8QerNPOu4iPbbPi>J}KLv%wd6p8^@9W}2} zG_gt7vJtjiD6#s+C621q0lmL6G_et|cBPmP9q&z*@RxriUg_4AYy|Jf$)tl(KN>W?d#D2NWm!tj>PdOCq9<^)0w(IuzZ#b$45X zxk{fT1W-aaq;n-pSQNp)0?n=f@_b||=ROHZG?hQ^ZY_;mq;&hUc9tCQ`NPS7GL2{f zWLV7PaCo2yU;4;j(!nZFkQ{gYccaN;Z@!q%V*#qpsgrblX5pIaa#1-mW_{E)rQnU; zctQ}BKwAR@2L?Rzl>Fqdxi-(5-NR1?MrI-vKg#g19*Thi@|SY)@f~_1)}+*9fEDcT zb8Q~8If1*J;5dOA69b_2_k({^)WwS=c3^M4zaFB4lHw;w`EO~k9YJX1*%&_6C#4P) zELnM<0g4p;b`sMZYfKDekm>i;E*-!H^MLC&1R*tKP4~Bo6T)(6Lj&SbgNDRE9#D=K z4zqsTsxkWR*y~`|7Fe6`X9jj|BeEy?;-p5oY-IkOIr#HZmNdxzS=zuLvwnBr{DmBT|Hk3Vfmr6H|p&2i* zyQLyFe4eV-i(~KPCR~V8YfdT>%+UL+XUFxESwkDU{9quN3HbF6VG zoJ!K`*opxlgmeF+nR@+;FlY#6HeOjbe#=+y z!4{v(;{y-QsOJL=#K*&l?w|uk|I#xGuL2FB#7+j!M0P-#^)H?~g-1fhL|i=YJF5Qp zZh!P8IbsMYtO7xk>%It{Uln#pwFPLtewH(NuWLx2;Mh3%W3l2^Nw8MU@3}k2U5B(> zi&6LxSD}2_E7|N?_N3EL19Lf}$8}ko5;iIuayg}66daM#9xHp=Cjm}Nea_w+<&V1e z%5?OfPF%HwU9)3zpx)$n(dQ}eMo+H^we|1!f3z|bOvO-1w1t~Z4O)I9Qx3DhL1jc;QGK`iO{e?F>ltou-$ST#J0fy z-v$m)zsJzvK|AQ~yZTIQ*pZZ$^Xg-f5E0|R+p;&D76Yc$FCvEra}n1hpjNh%GGD$r z-B-qp3N|B75|urN)FG{{WFa*PJQ @@ -145,8 +147,8 @@ export function ExplorerLayout() { /> - {/* Hide inspector on Overview screen */} - {inspectorVisible && !isOverview && ( + {/* Hide inspector on Overview screen and Knowledge view (has its own) */} + {inspectorVisible && !isOverview && !isKnowledgeView && ( { + const pathParam = searchParams.get("path"); + if (pathParam) { + try { + const sdPath = JSON.parse(decodeURIComponent(pathParam)); + const currentPathStr = JSON.stringify(currentPath); + const newPathStr = JSON.stringify(sdPath); + + if (currentPathStr !== newPathStr) { + console.log("Setting currentPath from query param:", sdPath); + setCurrentPath(sdPath); + } + } catch (e) { + console.error("Failed to parse path query parameter:", e); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + // Set currentPath from location ID (only when location changes) useEffect(() => { if (locationId && locationsQuery.data?.locations) { @@ -141,6 +163,8 @@ export function ExplorerView() { ) : viewMode === "size" ? ( + ) : viewMode === "knowledge" ? ( + ) : ( )} diff --git a/packages/interface/src/components/Explorer/TagAssignmentMode.tsx b/packages/interface/src/components/Explorer/TagAssignmentMode.tsx index 139ad7888..cca5cdbef 100644 --- a/packages/interface/src/components/Explorer/TagAssignmentMode.tsx +++ b/packages/interface/src/components/Explorer/TagAssignmentMode.tsx @@ -29,12 +29,13 @@ export function TagAssignmentMode({ isActive, onExit }: TagAssignmentModeProps) // Fetch all tags (for now, we'll use the first 10 as the default palette) // TODO: Implement user-defined palettes const { data: tagsData } = useNormalizedQuery({ - wireMethod: 'query:tags.list', - input: null, + wireMethod: 'query:tags.search', + input: { query: '' }, resourceType: 'tag' }); - const allTags = tagsData?.tags ?? []; + // Extract tags from search results (tags is an array of { tag, relevance, ... }) + const allTags = tagsData?.tags?.map((result: any) => result.tag) ?? []; const paletteTags = allTags.slice(0, 10); // First 10 tags for now // Keyboard shortcuts diff --git a/packages/interface/src/components/Explorer/ViewModeMenu.tsx b/packages/interface/src/components/Explorer/ViewModeMenu.tsx index c836fb7ad..7bfab10c2 100644 --- a/packages/interface/src/components/Explorer/ViewModeMenu.tsx +++ b/packages/interface/src/components/Explorer/ViewModeMenu.tsx @@ -9,11 +9,12 @@ import { ChartPieSlice, Clock, SquaresFour, + Sparkle, } from "@phosphor-icons/react"; import clsx from "clsx"; import { TopBarButton } from "@sd/ui"; -type ViewMode = "list" | "grid" | "column" | "media" | "size"; +type ViewMode = "list" | "grid" | "column" | "media" | "size" | "knowledge"; interface ViewOption { id: ViewMode | "timeline"; @@ -59,12 +60,19 @@ const viewOptions: ViewOption[] = [ color: "bg-green-500", keybind: "⌘5", }, + { + id: "knowledge", + label: "Knowledge", + icon: Sparkle, + color: "bg-purple-500", + keybind: "⌘6", + }, { id: "timeline", label: "Timeline", icon: Clock, color: "bg-yellow-500", - keybind: "⌘6", + keybind: "⌘7", }, ]; @@ -136,9 +144,9 @@ export function ViewModeMenu({ top: `${position.top}px`, right: `${position.right}px`, }} - className="w-[280px] rounded-lg bg-menu border border-menu-line shadow-2xl p-2 z-50" + className="w-[240px] rounded-lg bg-menu border border-menu-line shadow-2xl p-2 z-50" > -
+
{viewOptions.map((option) => ( + ))} +
+ + ); + } + + // Render provider selection for cloud + if (step === "provider" && selectedCategory === "cloud") { + return ( + } + description="Choose your cloud storage service" + className="w-[640px]" + onCancelled={true} + hideButtons={true} + buttonsSideContent={ + + } + > +
+ {cloudProviders.map((provider) => ( + + ))} +
+
+ ); + } + + // Render provider selection for network + if (step === "provider" && selectedCategory === "network") { + return ( + } + description="Choose your network file protocol" + className="w-[640px]" + onCancelled={true} + hideButtons={true} + buttonsSideContent={ + + } + > +
+
+ Coming Soon +

+ Network protocol support (SMB, NFS, SFTP, WebDAV) is currently in + development. Check back in a future update! +

+
+
+ {networkProtocols.map((protocol) => ( + + ))} +
+
+
+ ); + } + + // Render provider selection for external + if (step === "provider" && selectedCategory === "external") { + return ( + } + description="Select a connected drive to track" + className="w-[640px]" + onCancelled={true} + hideButtons={true} + buttonsSideContent={ + + } + > +
+ {volumes && volumes.length > 0 ? ( +
+ {volumes.map((volume) => ( + + ))} +
+ ) : ( +
+

+ No untracked external drives found. Connect a drive and refresh + to see it here. +

+
+ )} +
+
+ ); + } + + // Render local folder configuration (browse + suggested + settings) + if (step === "provider" && selectedCategory === "local") { + return ( + } + description="Choose a folder to index and manage" + className="w-[640px]" + onCancelled={true} + hideButtons={true} + buttonsSideContent={ + + } + > +
+
+ +
+ localForm.setValue("path", e.target.value)} + placeholder="Select a custom folder" + size="lg" + className="pr-14" + /> + +
+
+ + {suggestedLocations && suggestedLocations.locations.length > 0 && ( +
+ +
+ {suggestedLocations.locations.map((loc) => ( + + ))} +
+
+ )} +
+
+ ); + } + + // Render local folder settings (after path selected) + if (step === "local-config") { + return ( + } + description={localForm.watch("path")} + ctaLabel="Add Location" + onCancelled={true} + loading={addLocation.isPending} + className="w-[640px]" + buttonsSideContent={ + + } + > +
+
+ + +
+ + setTab(v as SettingsTab)}> + + Preset + + Jobs {selectedJobs.size > 0 && `(${selectedJobs.size})`} + + + + +
+ +
+ {indexModes.map((mode) => { + const isSelected = currentMode === mode.value; + return ( + + ); + })} +
+
+
+ + +
+

+ Select which jobs to run after indexing. Extensions can add + more jobs. +

+
+ {jobOptions.map((job) => { + const isSelected = selectedJobs.has(job.id); + return ( + + ); + })} +
+
+
+
+ + {localForm.formState.errors.root && ( +

+ {localForm.formState.errors.root.message} +

+ )} +
+
+ ); + } + + // Render cloud configuration form + if (step === "cloud-config" && selectedProvider) { + const provider = selectedProvider; + const isS3Type = + provider.cloudServiceType === "s3" || + provider.cloudServiceType === "b2" || + provider.cloudServiceType === "wasabi" || + provider.cloudServiceType === "spaces"; + const isOAuthType = + provider.cloudServiceType === "gdrive" || + provider.cloudServiceType === "dropbox" || + provider.cloudServiceType === "onedrive"; + const isAzureType = provider.cloudServiceType === "azblob"; + const isGCSType = provider.cloudServiceType === "gcs"; + + return ( + } + description="Configure your cloud storage connection" + ctaLabel="Add Storage" + onCancelled={true} + loading={addCloudVolume.isPending} + className="w-[640px]" + buttonsSideContent={ + + } + > +
+
+ + +
+ + {isS3Type && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {(provider.id === "r2" || + provider.id === "minio" || + provider.id === "wasabi" || + provider.id === "spaces") && ( +
+ + +
+ )} + + )} + + {isOAuthType && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + )} + + {isAzureType && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + )} + + {isGCSType && ( + <> +
+ + +
+
+ +