diff --git a/docs/web_api.md b/docs/web_api.md index 8ba7ff2..a085979 100644 --- a/docs/web_api.md +++ b/docs/web_api.md @@ -452,6 +452,41 @@ Deletes a directory at the specified path. - Returns:\ `ok` if successful +### Move a file or directory +Moves a file or directory from one location to another. Note that the following +conditions must be met for a move successful move: +- The source must exist +- The user (typically "Pi") must have the appropriate file permissions +- Neither the source nor destination can be loaded by the virtual_sdcard. + If the source or destination is a directory, it cannot contain a file + loaded by the virtual_sdcard. + +When specifying the `source` and `dest`, the "root" directory should be prefixed. +Currently the only supported root is "gcodes/". + +This API may also be used to rename a file or directory. Be aware that an attempt +to rename a directory to a directory that already exists will result in *moving* the +source directory to the destination directory. + +- HTTP command:\ + `POST /server/files/move?source=gcodes/my_file.gcode&dest=gcodes/subdir/my_file.gcode` + +- Websocket command:\ + `{jsonrpc: "2.0", method: "post_file_move", params: {source: "gcodes/my_file.gcode", + dest: "gcodes/subdir/my_file.gcode"}, id: }` + +### Copy a file or directory +Copies a file or directory from one location to another. A successful copy has +the pre-requesites as a move with one exception, a copy may complete if the +source file/directory is loaded by the virtual_sdcard. As with the move API, the +source and destination should have the root prefixed. + +- HTTP command:\ + `POST /server/files/copy?source=gcodes/my_file.gcode&dest=gcodes/subdir/my_file.gcode` + +- Websocket command:\ + `{jsonrpc: "2.0", method: "post_file_copy", params: {source: "gcodes/my_file.gcode", + dest: "gcodes/subdir/my_file.gcode"}, id: }` ### Gcode File Download - HTTP command:\ diff --git a/moonraker/plugins/file_manager.py b/moonraker/plugins/file_manager.py index 23257a2..9e760a0 100644 --- a/moonraker/plugins/file_manager.py +++ b/moonraker/plugins/file_manager.py @@ -32,6 +32,12 @@ class FileManager: self.server.register_endpoint( "/server/files/directory", "directory", ['GET', 'POST', 'DELETE'], self._handle_directory_request) + self.server.register_endpoint( + "/server/files/move", "file_move", ['POST'], + self._handle_file_move_copy) + self.server.register_endpoint( + "/server/files/copy", "file_copy", ['POST'], + self._handle_file_move_copy) def _register_static_files(self, gcode_path): self.server.register_static_file_handler( @@ -70,14 +76,8 @@ class FileManager: return metadata async def _handle_directory_request(self, path, method, args): - directory = args.get('path', "gcodes").strip('/') - dir_parts = directory.split("/") - base = dir_parts[0] - target = "/".join(dir_parts[1:]) - if base not in self.file_paths: - raise self.server.error("Invalid base path (%s)" % (base)) - root_path = self.file_paths[base] - dir_path = os.path.join(root_path, target) + directory = args.get('path', "gcodes") + base, dir_path = self._convert_path(directory) method = method.upper() if method == 'GET': # Get list of files and subdirectories for this target @@ -90,7 +90,7 @@ class FileManager: raise self.server.error(str(e)) elif method == 'DELETE' and base == "gcodes": # Remove a directory - if directory == base: + if directory.strip("/") == base: raise self.server.error( "Cannot delete root directory") if not os.path.isdir(dir_path): @@ -104,6 +104,10 @@ class FileManager: # loaded by the virtual_sdcard await self._handle_operation_check(dir_path) shutil.rmtree(dir_path) + # since it is possible that the directory contains + # files, send a notification to clients + self.server.notify_filelist_changed( + directory, "delete_directory") else: try: os.rmdir(dir_path) @@ -133,6 +137,57 @@ class FileManager: ongoing = vsd.get('total_duration', 0.) > 0. return ongoing + def _convert_path(self, url_path): + parts = url_path.strip("/").split("/") + if not parts: + raise self.server.error("Invalid path: " % (url_path)) + base = parts[0] + if base not in self.file_paths: + raise self.server.error("Invalid base path (%s)" % (base)) + root_path = local_path = self.file_paths[base] + if len(parts) > 1: + target = "/".join(parts[1:]) + local_path = os.path.join(root_path, target) + return base, local_path + + async def _handle_file_move_copy(self, path, method, args): + source = args.get("source") + destination = args.get("dest") + if source is None: + raise self.server.error("File move/copy request issing source") + if destination is None: + raise self.server.error("File move/copy request missing destination") + source_base, source_path = self._convert_path(source) + dest_base, dest_path = self._convert_path(destination) + if source_base != "gcodes" or dest_base != "gcodes": + raise self.server.error( + "Unsupported root directory: source=%s base=%s" % + (source_base, dest_base)) + if not os.path.exists(source_path): + raise self.server.error("File %s does not exist" % (source_path)) + # make sure the destination is not in use + if os.path.exists(dest_path): + await self._handle_operation_check(dest_path) + if path == "/server/files/move": + # if moving the file, make sure the source is not in use + await self._handle_operation_check(source_path) + try: + shutil.move(source_path, dest_path) + except Exception as e: + raise self.server.error(str(e)) + action = "file_move" + elif path == "/server/files/copy": + try: + if os.path.isdir(source_path): + shutil.copytree(source_path, dest_path) + else: + shutil.copy2(source_path, dest_path) + except Exception as e: + raise self.server.error(str(e)) + action = "file_copy" + self.server.notify_filelist_changed(destination, action) + return "ok" + def _list_directory(self, path): if not os.path.isdir(path): raise self.server.error(