use anyhow::{Context, Result}; use rusqlite::Connection; use std::str::FromStr; use uuid::Uuid; use crate::filetree::node::{Aliases, FileNode, NodeType}; pub mod convert; pub mod mode; pub mod modes; pub mod node; pub struct FileTree { pub user_id: String, pub nodes: Vec, } const CREATE_TABLES: &str = " CREATE TABLE IF NOT EXISTS file_registry ( file_uuid TEXT PRIMARY KEY, original_name TEXT NOT NULL, file_size INTEGER, file_type TEXT, registered_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen_at TEXT, status TEXT NOT NULL DEFAULT 'active' ); CREATE TABLE IF NOT EXISTS file_nodes ( node_id TEXT PRIMARY KEY, label TEXT NOT NULL, aliases_json TEXT NOT NULL DEFAULT '{}', file_uuid TEXT, sha256 TEXT, parent_id TEXT, children_json TEXT NOT NULL DEFAULT '[]', node_type TEXT NOT NULL DEFAULT 'folder', icon TEXT, color TEXT, bg_color TEXT, file_size INTEGER, registered_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS file_locations ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_uuid TEXT NOT NULL, location TEXT NOT NULL, label TEXT, added_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(file_uuid, location) ); "; impl FileTree { pub fn user_db_path(user_id: &str) -> String { format!("data/users/{}.sqlite", user_id) } pub fn init_user_db(user_id: &str) -> Result { let db_path = Self::user_db_path(user_id); let parent = std::path::Path::new(&db_path).parent().unwrap(); std::fs::create_dir_all(parent)?; let conn = Connection::open(&db_path)?; conn.execute_batch(CREATE_TABLES)?; Ok(conn) } pub fn open_user_db(user_id: &str) -> Result { let db_path = Self::user_db_path(user_id); Connection::open(&db_path).with_context(|| format!("Failed to open {}", db_path)) } pub fn load(conn: &Connection, user_id: &str) -> Result { let mut stmt = conn.prepare( "SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json, node_type, icon, color, bg_color, file_size, registered_at, created_at, updated_at, sort_order FROM file_nodes ORDER BY sort_order ASC, created_at ASC", )?; let nodes: Vec = stmt .query_map([], |row| { let children_json: String = row.get(6)?; let children: Vec = serde_json::from_str(&children_json).unwrap_or_default(); Ok(FileNode { node_id: row.get(0)?, label: row.get(1)?, aliases: Aliases::from_json(&row.get::<_, String>(2)?), file_uuid: row.get(3)?, sha256: row.get(4)?, parent_id: row.get(5)?, children, node_type: NodeType::from_str(&row.get::<_, String>(7)?) .unwrap_or(NodeType::Folder), icon: row.get(8)?, color: row.get(9)?, bg_color: row.get(10)?, file_size: row.get(11)?, registered_at: row.get(12)?, created_at: row.get(13)?, updated_at: row.get(14)?, sort_order: row.get(15)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(FileTree { user_id: user_id.to_string(), nodes, }) } pub fn insert_node(&mut self, conn: &Connection, node: &FileNode) -> Result<()> { conn.execute( "INSERT INTO file_nodes (node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json, node_type, icon, color, bg_color, file_size, registered_at, created_at, updated_at, sort_order) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", rusqlite::params![ node.node_id, node.label, node.aliases.to_json(), node.file_uuid, node.sha256, node.parent_id, serde_json::to_string(&node.children).unwrap_or_else(|_| "[]".to_string()), node.node_type.as_str(), node.icon, node.color, node.bg_color, node.file_size, node.registered_at, node.created_at, node.updated_at, node.sort_order, ], )?; self.nodes.push(node.clone()); Ok(()) } pub fn update_node( &mut self, conn: &Connection, node_id: &str, updated: &FileNode, ) -> Result<()> { conn.execute( "UPDATE file_nodes SET label=?1, aliases_json=?2, file_uuid=?3, sha256=?4, parent_id=?5, children_json=?6, node_type=?7, icon=?8, color=?9, bg_color=?10, file_size=?11, registered_at=?12, updated_at=?13, sort_order=?14 WHERE node_id=?15", rusqlite::params![ updated.label, updated.aliases.to_json(), updated.file_uuid, updated.sha256, updated.parent_id, serde_json::to_string(&updated.children) .unwrap_or_else(|_| "[]".to_string()), updated.node_type.as_str(), updated.icon, updated.color, updated.bg_color, updated.file_size, updated.registered_at, updated.updated_at, updated.sort_order, node_id, ], )?; if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) { *n = updated.clone(); } Ok(()) } pub fn update_node_alias( &mut self, conn: &Connection, node_id: &str, lang: &str, value: &str, ) -> Result<()> { let node = self .nodes .iter_mut() .find(|n| n.node_id == node_id) .context("Node not found")?; node.aliases.set(lang, value); let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); node.updated_at = now.clone(); conn.execute( "UPDATE file_nodes SET aliases_json=?1, updated_at=?2 WHERE node_id=?3", rusqlite::params![node.aliases.to_json(), now, node_id], )?; Ok(()) } pub fn delete_node(&mut self, conn: &Connection, node_id: &str) -> Result<()> { conn.execute("DELETE FROM file_nodes WHERE node_id=?1", [node_id])?; self.nodes.retain(|n| n.node_id != node_id); Ok(()) } pub fn move_node( &mut self, conn: &Connection, node_id: &str, new_parent_id: Option, ) -> Result<()> { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); conn.execute( "UPDATE file_nodes SET parent_id=?1, updated_at=?2 WHERE node_id=?3", rusqlite::params![new_parent_id, now, node_id], )?; if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) { n.parent_id = new_parent_id; n.updated_at = now; } Ok(()) } pub fn build_tree(&self) -> Vec { let mut roots: Vec = self .nodes .iter() .filter(|n| n.parent_id.is_none()) .cloned() .collect(); for root in &mut roots { self.fill_children(root); } roots } fn fill_children(&self, node: &mut FileNode) { let child_ids: Vec = node.children.clone(); let mut children: Vec = self .nodes .iter() .filter(|n| { n.parent_id.as_deref() == Some(&node.node_id) && child_ids.contains(&n.node_id) }) .cloned() .collect(); for child in &mut children { self.fill_children(child); } node.children = children.iter().map(|c| c.node_id.clone()).collect(); node.children .extend(children.iter().flat_map(|c| c.children.clone())); } pub fn new_folder(label: &str, parent_id: Option) -> FileNode { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); FileNode { node_id: Uuid::new_v4().to_string(), label: label.to_string(), aliases: Aliases::empty(), file_uuid: None, sha256: None, parent_id, children: vec![], node_type: NodeType::Folder, icon: None, color: None, bg_color: None, file_size: None, registered_at: None, created_at: now.clone(), updated_at: now, sort_order: 0, } } #[allow(clippy::too_many_arguments)] pub fn new_file_node( label: &str, file_uuid: &str, sha256: Option<&str>, original_name: &str, file_size: Option, file_type: Option<&str>, registered_at: Option<&str>, parent_id: Option, ) -> (FileNode, Option) { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let reg_at = registered_at .map(|s| s.to_string()) .unwrap_or_else(|| now.clone()); let node = FileNode { node_id: Uuid::new_v4().to_string(), label: label.to_string(), aliases: Aliases::empty(), file_uuid: Some(file_uuid.to_string()), sha256: sha256.map(|s| s.to_string()), parent_id, children: vec![], node_type: NodeType::File, icon: None, color: None, bg_color: None, file_size, registered_at: Some(reg_at), created_at: now.clone(), updated_at: now.clone(), sort_order: 0, }; let register_sql = format!( "INSERT OR REPLACE INTO file_registry (file_uuid, original_name, file_size, file_type, registered_at, status) VALUES ('{}', '{}', {}, {}, '{}', 'active')", file_uuid, original_name.replace('\'', "''"), file_size.map_or("NULL".to_string(), |s| s.to_string()), file_type.map_or("NULL".to_string(), |t| format!("'{}'", t.replace('\'', "''"))), now, ); (node, Some(register_sql)) } pub fn add_location( conn: &Connection, file_uuid: &str, location: &str, label: Option<&str>, ) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO file_locations (file_uuid, location, label) VALUES (?1, ?2, ?3)", rusqlite::params![file_uuid, location, label], )?; Ok(()) } pub fn get_file_info(conn: &Connection, file_uuid: &str) -> Result { let mut virtual_paths: Vec = Vec::new(); let mut stmt = conn.prepare( "SELECT fn.node_id, fn.label, fn.parent_id FROM file_nodes fn WHERE fn.file_uuid = ?1", )?; let node_rows: Vec<(String, String, Option)> = stmt .query_map([file_uuid], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, )) })? .filter_map(|r| r.ok()) .collect(); for (_nid, _label, _parent_id) in &node_rows { let mut path_parts: Vec = vec![]; let mut current = _parent_id.clone(); while let Some(pid) = current { let folder: Option<(String, Option)> = conn .query_row( "SELECT label, parent_id FROM file_nodes WHERE node_id = ?1", [&pid], |row| Ok((row.get(0)?, row.get(1)?)), ) .ok(); if let Some((name, next_pid)) = folder { path_parts.push(name); current = next_pid; } else { break; } } path_parts.reverse(); virtual_paths.push(path_parts.join(" / ")); } let mut real_locations: Vec = Vec::new(); let mut lstmt = conn.prepare( "SELECT location, label, added_at FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at", )?; let locs: Vec<(String, Option, String)> = lstmt .query_map([file_uuid], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, )) })? .filter_map(|r| r.ok()) .collect(); for (loc, lbl, added) in &locs { real_locations.push(serde_json::json!({ "path": loc, "label": lbl, "added_at": added, })); } Ok(serde_json::json!({ "file_uuid": file_uuid, "virtual_paths": virtual_paths, "real_locations": real_locations, "node_count": node_rows.len(), "location_count": locs.len(), })) } } #[cfg(test)] mod tests { use super::*; fn temp_db() -> (Connection, String) { let user_id = format!("test_{}", Uuid::new_v4()); let conn = FileTree::init_user_db(&user_id).unwrap(); (conn, user_id) } #[test] fn test_init_and_load_empty_tree() { let (conn, user_id) = temp_db(); let tree = FileTree::load(&conn, &user_id).unwrap(); assert_eq!(tree.user_id, user_id); assert!(tree.nodes.is_empty()); } #[test] fn test_insert_and_load_node() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let folder = FileTree::new_folder("Videos", None); tree.insert_node(&conn, &folder).unwrap(); let loaded = FileTree::load(&conn, &user_id).unwrap(); assert_eq!(loaded.nodes.len(), 1); assert_eq!(loaded.nodes[0].label, "Videos"); assert_eq!(loaded.nodes[0].node_type, NodeType::Folder); } #[test] fn test_update_node() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let mut folder = FileTree::new_folder("Videos", None); tree.insert_node(&conn, &folder).unwrap(); folder.label = "Movies".to_string(); folder.icon = Some("📽️".to_string()); folder.color = Some("#ff0000".to_string()); tree.update_node(&conn, &folder.node_id, &folder).unwrap(); let loaded = FileTree::load(&conn, &user_id).unwrap(); assert_eq!(loaded.nodes[0].label, "Movies"); assert_eq!(loaded.nodes[0].icon, Some("📽️".to_string())); assert_eq!(loaded.nodes[0].color, Some("#ff0000".to_string())); } #[test] fn test_delete_node() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let folder = FileTree::new_folder("Temp", None); let node_id = folder.node_id.clone(); tree.insert_node(&conn, &folder).unwrap(); tree.delete_node(&conn, &node_id).unwrap(); let loaded = FileTree::load(&conn, &user_id).unwrap(); assert_eq!(loaded.nodes.len(), 0); } #[test] fn test_move_node() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let root = FileTree::new_folder("Root", None); let child = FileTree::new_folder("Child", Some(root.node_id.clone())); tree.insert_node(&conn, &root).unwrap(); tree.insert_node(&conn, &child).unwrap(); tree.move_node(&conn, &child.node_id, None).unwrap(); let loaded = FileTree::load(&conn, &user_id).unwrap(); let moved = loaded.nodes.iter().find(|n| n.label == "Child").unwrap(); assert!(moved.parent_id.is_none()); } #[test] fn test_update_alias() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let folder = FileTree::new_folder("Videos", None); tree.insert_node(&conn, &folder).unwrap(); tree.update_node_alias(&conn, &folder.node_id, "zh_tw", "影片") .unwrap(); let loaded = FileTree::load(&conn, &user_id).unwrap(); assert_eq!( loaded.nodes[0].aliases.get("zh_tw").map(|s| s.as_str()), Some("影片") ); } #[test] fn test_build_tree() { let (conn, user_id) = temp_db(); let mut tree = FileTree::load(&conn, &user_id).unwrap(); let root = FileTree::new_folder("Root", None); let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone())); let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone())); let grandchild = FileTree::new_folder("Grandchild", Some(child1.node_id.clone())); // Set parent-child relationships let mut root_with_children = root.clone(); root_with_children.children = vec![child1.node_id.clone(), child2.node_id.clone()]; let mut child1_with_children = child1.clone(); child1_with_children.children = vec![grandchild.node_id.clone()]; tree.insert_node(&conn, &root_with_children).unwrap(); tree.insert_node(&conn, &child1_with_children).unwrap(); tree.insert_node(&conn, &child2).unwrap(); tree.insert_node(&conn, &grandchild).unwrap(); let roots = tree.build_tree(); assert_eq!(roots.len(), 1); assert_eq!(roots[0].label, "Root"); } }