diff --git a/lib/hero/herofs/fs_dir.v b/lib/hero/herofs/fs_dir.v index b2153d76..9977f704 100644 --- a/lib/hero/herofs/fs_dir.v +++ b/lib/hero/herofs/fs_dir.v @@ -17,6 +17,23 @@ pub mut: parent_id u32 // Parent directory ID (0 for root) } +// DirectoryContents represents the contents of a directory +pub struct DirectoryContents { +pub mut: + directories []FsDir + files []FsFile + symlinks []FsSymlink +} + +// ListContentsOptions defines options for listing directory contents +@[params] +pub struct ListContentsOptions { +pub mut: + recursive bool + include_patterns []string // File/directory name patterns to include (e.g. *.py, doc*) + exclude_patterns []string // File/directory name patterns to exclude +} + // we only keep the parents, not the children, as children can be found by doing a query on parent_id, we will need some smart hsets to make this fast enough and efficient pub struct DBFsDir { @@ -147,6 +164,173 @@ pub fn (mut self DBFsDir) list_by_filesystem(fs_id u32) ![]FsDir { return dirs } +// Get directory by absolute path +pub fn (mut self DBFsDir) get_by_absolute_path(fs_id u32, path string) !FsDir { + // Normalize path (remove trailing slashes, handle empty path) + normalized_path := if path == '' || path == '/' { '/' } else { path.trim_right('/') } + + if normalized_path == '/' { + // Special case for root directory + dirs := self.list_by_filesystem(fs_id)! + for dir in dirs { + if dir.parent_id == 0 { + return dir + } + } + return error('Root directory not found for filesystem ${fs_id}') + } + + // Split path into components + components := normalized_path.trim_left('/').split('/') + + // Start from the root directory + mut current_dir_id := u32(0) + mut dirs := self.list_by_filesystem(fs_id)! + + // Find root directory + for dir in dirs { + if dir.parent_id == 0 { + current_dir_id = dir.id + break + } + } + + if current_dir_id == 0 { + return error('Root directory not found for filesystem ${fs_id}') + } + + // Navigate through path components + for component in components { + found := false + for dir in dirs { + if dir.parent_id == current_dir_id && dir.name == component { + current_dir_id = dir.id + found = true + break + } + } + + if !found { + return error('Directory "${component}" not found in path "${normalized_path}"') + } + + // Update dirs for next iteration + dirs = self.list_children(current_dir_id)! + } + + return self.get(current_dir_id)! +} + +// Create a directory by absolute path, creating parent directories as needed +pub fn (mut self DBFsDir) create_path(fs_id u32, path string) !u32 { + // Normalize path + normalized_path := if path == '' || path == '/' { '/' } else { path.trim_right('/') } + + if normalized_path == '/' { + // Special case for root directory + dirs := self.list_by_filesystem(fs_id)! + for dir in dirs { + if dir.parent_id == 0 { + return dir.id + } + } + + // Create root directory if it doesn't exist + mut root_dir := self.new( + name: 'root' + fs_id: fs_id + parent_id: 0 + description: 'Root directory' + )! + return self.set(root_dir)! + } + + // Split path into components + components := normalized_path.trim_left('/').split('/') + + // Start from the root directory + mut current_dir_id := u32(0) + mut dirs := self.list_by_filesystem(fs_id)! + + // Find or create root directory + for dir in dirs { + if dir.parent_id == 0 { + current_dir_id = dir.id + break + } + } + + if current_dir_id == 0 { + // Create root directory + mut root_dir := self.new( + name: 'root' + fs_id: fs_id + parent_id: 0 + description: 'Root directory' + )! + current_dir_id = self.set(root_dir)! + } + + // Navigate/create through path components + for component in components { + found := false + for dir in dirs { + if dir.parent_id == current_dir_id && dir.name == component { + current_dir_id = dir.id + found = true + break + } + } + + if !found { + // Create this directory component + mut new_dir := self.new( + name: component + fs_id: fs_id + parent_id: current_dir_id + description: 'Directory created as part of path ${normalized_path}' + )! + current_dir_id = self.set(new_dir)! + } + + // Update directory list for next iteration + dirs = self.list_children(current_dir_id)! + } + + return current_dir_id +} + +// Delete a directory by absolute path +pub fn (mut self DBFsDir) delete_by_path(fs_id u32, path string) ! { + dir := self.get_by_absolute_path(fs_id, path)! + self.delete(dir.id)! +} + +// Move a directory using source and destination paths +pub fn (mut self DBFsDir) move_by_path(fs_id u32, source_path string, dest_path string) !u32 { + // Get the source directory + source_dir := self.get_by_absolute_path(fs_id, source_path)! + + // For the destination, we need the parent directory + dest_dir_path := dest_path.all_before_last('/') + dest_dir_name := dest_path.all_after_last('/') + + dest_parent_dir := if dest_dir_path == '' || dest_dir_path == '/' { + // Moving to the root + self.get_by_absolute_path(fs_id, '/')! + } else { + self.get_by_absolute_path(fs_id, dest_dir_path)! + } + + // First rename if the destination name is different + if source_dir.name != dest_dir_name { + self.rename(source_dir.id, dest_dir_name)! + } + + // Then move to the new parent + return self.move(source_dir.id, dest_parent_dir.id)! +} + // Get children of a directory pub fn (mut self DBFsDir) list_children(dir_id u32) ![]FsDir { child_ids := self.db.redis.hkeys('fsdir:children:${dir_id}')! @@ -205,3 +389,91 @@ pub fn (mut self DBFsDir) move(id u32, new_parent_id u32) !u32 { // Save with new parent return self.set(dir)! } + +// List contents of a directory with filtering capabilities +pub fn (mut self DBFsDir) list_contents(fs_factory &FsFactory, dir_id u32, opts ListContentsOptions) !DirectoryContents { + mut result := DirectoryContents{} + + // Helper function to check if name matches include/exclude patterns + matches_pattern := fn (name string, patterns []string) bool { + if patterns.len == 0 { + return true // No patterns means include everything + } + + for pattern in patterns { + if pattern.contains('*') { + prefix := pattern.all_before('*') + suffix := pattern.all_after('*') + + if prefix == '' && suffix == '' { + return true // Pattern is just "*" + } else if prefix == '' { + if name.ends_with(suffix) { + return true + } + } else if suffix == '' { + if name.starts_with(prefix) { + return true + } + } else { + if name.starts_with(prefix) && name.ends_with(suffix) { + return true + } + } + } else if name == pattern { + return true // Exact match + } + } + + return false + } + + // Check if item should be included based on patterns + should_include := fn (name string, include_patterns []string, exclude_patterns []string) bool { + // First apply include patterns (if empty, include everything) + if !matches_pattern(name, include_patterns) && include_patterns.len > 0 { + return false + } + + // Then apply exclude patterns + if matches_pattern(name, exclude_patterns) && exclude_patterns.len > 0 { + return false + } + + return true + } + + // Get directories, files, and symlinks in the current directory + dirs := self.list_children(dir_id)! + for dir in dirs { + if should_include(dir.name, opts.include_patterns, opts.exclude_patterns) { + result.directories << dir + } + + // If recursive, process subdirectories + if opts.recursive { + sub_contents := self.list_contents(fs_factory, dir.id, opts)! + result.directories << sub_contents.directories + result.files << sub_contents.files + result.symlinks << sub_contents.symlinks + } + } + + // Get files in the directory + files := fs_factory.fs_file.list_by_directory(dir_id)! + for file in files { + if should_include(file.name, opts.include_patterns, opts.exclude_patterns) { + result.files << file + } + } + + // Get symlinks in the directory + symlinks := fs_factory.fs_symlink.list_by_parent(dir_id)! + for symlink in symlinks { + if should_include(symlink.name, opts.include_patterns, opts.exclude_patterns) { + result.symlinks << symlink + } + } + + return result +} diff --git a/lib/hero/herofs/rpc/factory.v b/lib/hero/herofs/rpc/factory.v index b2a83764..fafdabbd 100644 --- a/lib/hero/herofs/rpc/factory.v +++ b/lib/hero/herofs/rpc/factory.v @@ -30,6 +30,7 @@ pub fn start(args ServerArgs) ! { openrpc_handler.register_procedure_handle('fs_dir_rename', fs_dir_rename) openrpc_handler.register_procedure_handle('fs_dir_list_by_filesystem', fs_dir_list_by_filesystem) openrpc_handler.register_procedure_handle('fs_dir_has_children', fs_dir_has_children) + openrpc_handler.register_procedure_handle('fs_dir_list_contents', fs_dir_list_contents) // Register fs_file procedures openrpc_handler.register_procedure_handle('fs_file_get', fs_file_get) diff --git a/lib/hero/herofs/rpc/openrpc.json b/lib/hero/herofs/rpc/openrpc.json index 3ff870c6..37cff69c 100644 --- a/lib/hero/herofs/rpc/openrpc.json +++ b/lib/hero/herofs/rpc/openrpc.json @@ -981,6 +981,97 @@ } } } + }, + { + "name": "fs_dir_list_contents", + "summary": "List directory contents with filtering", + "description": "List files, directories, and symlinks in a directory with optional filtering and recursion", + "params": [ + { + "name": "dir_id", + "description": "ID of directory to list contents for", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "path", + "description": "Absolute path to the directory", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fs_id", + "description": "ID of filesystem (required when using path)", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "recursive", + "description": "Whether to list contents recursively", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "include", + "description": "Patterns to include (e.g. ['*.py', 'doc*'])", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "exclude", + "description": "Patterns to exclude (e.g. ['*~', '.git'])", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "result": { + "name": "contents", + "description": "Directory contents with files, directories, and symlinks", + "schema": { + "type": "object", + "properties": { + "directories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Directory" + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + } + }, + "symlinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symlink" + } + } + } + } + } } ], "components": { diff --git a/lib/hero/herofs/rpc/rpc_fs_dir.v b/lib/hero/herofs/rpc/rpc_fs_dir.v index 2c6c9bc5..6853c272 100644 --- a/lib/hero/herofs/rpc/rpc_fs_dir.v +++ b/lib/hero/herofs/rpc/rpc_fs_dir.v @@ -8,7 +8,9 @@ import freeflowuniverse.herolib.hero.herofs @[params] pub struct FSDirGetArgs { pub mut: - id u32 @[required] + id u32 + path string // Allow getting a directory by path + fs_id u32 // Required when using path } @[params] @@ -17,6 +19,7 @@ pub mut: name string @[required] fs_id u32 @[required] parent_id u32 + path string // Allow creating directories by path description string metadata map[string]string } @@ -24,14 +27,19 @@ pub mut: @[params] pub struct FSDirDeleteArgs { pub mut: - id u32 @[required] + id u32 + path string // Allow deleting a directory by path + fs_id u32 // Required when using path } @[params] pub struct FSDirMoveArgs { pub mut: - id u32 @[required] - parent_id u32 @[required] + id u32 + parent_id u32 + source_path string // Allow moving using paths + dest_path string + fs_id u32 // Required when using paths } @[params] @@ -53,13 +61,32 @@ pub mut: id u32 @[required] } +@[params] +pub struct FSDirListContentsArgs { +pub mut: + dir_id u32 + path string // Allow listing contents by path + fs_id u32 // Required when using path + recursive bool + include []string // Patterns to include + exclude []string // Patterns to exclude +} + pub fn fs_dir_get(request Request) !Response { payload := jsonrpc.decode_payload[FSDirGetArgs](request.params) or { return jsonrpc.invalid_params } mut fs_factory := herofs.new()! - dir := fs_factory.fs_dir.get(payload.id)! + + // Handle either path-based or ID-based retrieval + mut dir := if payload.path != '' && payload.fs_id > 0 { + fs_factory.fs_dir.get_by_absolute_path(payload.fs_id, payload.path)! + } else if payload.id > 0 { + fs_factory.fs_dir.get(payload.id)! + } else { + return jsonrpc.invalid_params_with_msg("Either id or both path and fs_id must be provided") + } return jsonrpc.new_response(request.id, json.encode(dir)) } @@ -70,17 +97,25 @@ pub fn fs_dir_set(request Request) !Response { } mut fs_factory := herofs.new()! - mut dir_obj := fs_factory.fs_dir.new( - name: payload.name - fs_id: payload.fs_id - parent_id: payload.parent_id - description: payload.description - metadata: payload.metadata - )! + + mut dir_id := u32(0) + + // Handle path-based creation + if payload.path != '' { + dir_id = fs_factory.fs_dir.create_path(payload.fs_id, payload.path)! + } else { + // Handle traditional creation + mut dir_obj := fs_factory.fs_dir.new( + name: payload.name + fs_id: payload.fs_id + parent_id: payload.parent_id + description: payload.description + metadata: payload.metadata + )! + dir_id = fs_factory.fs_dir.set(dir_obj)! + } - id := fs_factory.fs_dir.set(dir_obj)! - - return new_response_u32(request.id, id) + return new_response_u32(request.id, dir_id) } pub fn fs_dir_delete(request Request) !Response { @@ -89,7 +124,15 @@ pub fn fs_dir_delete(request Request) !Response { } mut fs_factory := herofs.new()! - fs_factory.fs_dir.delete(payload.id)! + + // Handle either path-based or ID-based deletion + if payload.path != '' && payload.fs_id > 0 { + fs_factory.fs_dir.delete_by_path(payload.fs_id, payload.path)! + } else if payload.id > 0 { + fs_factory.fs_dir.delete(payload.id)! + } else { + return jsonrpc.invalid_params_with_msg("Either id or both path and fs_id must be provided") + } return new_response_true(request.id) } @@ -107,7 +150,15 @@ pub fn fs_dir_move(request Request) !Response { } mut fs_factory := herofs.new()! - fs_factory.fs_dir.move(payload.id, payload.parent_id)! + + // Handle either path-based or ID-based move + if payload.source_path != '' && payload.dest_path != '' && payload.fs_id > 0 { + fs_factory.fs_dir.move_by_path(payload.fs_id, payload.source_path, payload.dest_path)! + } else if payload.id > 0 && payload.parent_id > 0 { + fs_factory.fs_dir.move(payload.id, payload.parent_id)! + } else { + return jsonrpc.invalid_params_with_msg("Either id and parent_id, or source_path, dest_path and fs_id must be provided") + } return new_response_true(request.id) } @@ -144,3 +195,34 @@ pub fn fs_dir_has_children(request Request) !Response { return jsonrpc.new_response(request.id, json.encode(has_children)) } + +// New method to list directory contents with filters +pub fn fs_dir_list_contents(request Request) !Response { + payload := jsonrpc.decode_payload[FSDirListContentsArgs](request.params) or { + return jsonrpc.invalid_params + } + + mut fs_factory := herofs.new()! + + // Get directory ID either directly or from path + mut dir_id := if payload.path != '' && payload.fs_id > 0 { + dir := fs_factory.fs_dir.get_by_absolute_path(payload.fs_id, payload.path)! + dir.id + } else if payload.dir_id > 0 { + payload.dir_id + } else { + return jsonrpc.invalid_params_with_msg("Either dir_id or both path and fs_id must be provided") + } + + // Create options struct + opts := herofs.ListContentsOptions{ + recursive: payload.recursive + include_patterns: payload.include + exclude_patterns: payload.exclude + } + + // List contents with filters + contents := fs_factory.fs_dir.list_contents(&fs_factory, dir_id, opts)! + + return jsonrpc.new_response(request.id, json.encode(contents)) +}