# Copyright (c) 2022 Ultimaker B.V. # Uranium is released under the terms of the LGPLv3 or higher. import os import sys from PyQt6.QtCore import QUrl from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import QFileDialog, QMessageBox from UM.Application import Application from UM.FileHandler.WriteFileJob import WriteFileJob from UM.Logger import Logger from UM.Mesh.MeshWriter import MeshWriter from UM.Message import Message from UM.OutputDevice import OutputDeviceError from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice from UM.Workspace.WorkspaceWriter import WorkspaceWriter from UM.i18n import i18nCatalog catalog = i18nCatalog("uranium") class LocalFileOutputDevice(ProjectOutputDevice): """Implements an OutputDevice that supports saving to arbitrary local files.""" def __init__(self, add_to_output_devices: bool = True, parent = None): super().__init__(device_id = "local_file", add_to_output_devices = add_to_output_devices, parent = parent) self.setName(catalog.i18nc("@item:inmenu", "Local File")) self.setShortDescription(catalog.i18nc("@action:button Preceded by 'Ready to'.", "Save to Disk")) self.setDescription(catalog.i18nc("@info:tooltip", "Save to Disk")) self.setIconName("save") self.shortcut = "Ctrl+S" self.menu_entry_text = catalog.i18nc("@item:inmenu About saving files to the hard drive", "To Disk") self._writing = False def requestWrite(self, nodes, file_name = None, limit_mimetypes = None, file_handler = None, **kwargs): """Request the specified nodes to be written to a file. :param nodes: A collection of scene nodes that should be written to the file. :param file_name: A suggestion for the file name to write to. Can be freely ignored if providing a file name makes no sense. :param limit_mimetypes: Should we limit the available MIME types to the MIME types available to the currently active machine? :param kwargs: Keyword arguments. """ if self._writing: raise OutputDeviceError.DeviceBusyError() # Set up and display file dialog dialog = QFileDialog() dialog.setWindowTitle(catalog.i18nc("@title:window", "Save to Disk")) dialog.setFileMode(QFileDialog.FileMode.AnyFile) dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) # Ensure platform never ask for overwrite confirmation since we do this ourselves dialog.setOption(QFileDialog.Option.DontConfirmOverwrite) filters = [] mime_types = [] selected_filter = None if "preferred_mimetypes" in kwargs and kwargs["preferred_mimetypes"] is not None: preferred_mimetypes = kwargs["preferred_mimetypes"] else: preferred_mimetypes = Application.getInstance().getPreferences().getValue("local_file/last_used_type") preferred_mimetype_list = preferred_mimetypes.split(";") if not file_handler: file_handler = Application.getInstance().getMeshFileHandler() file_types = file_handler.getSupportedFileTypesWrite() file_types.sort(key = lambda k: k["description"]) if limit_mimetypes: file_types = list(filter(lambda i: i["mime_type"] in limit_mimetypes, file_types)) file_types = [ft for ft in file_types if not ft["hide_in_file_dialog"]] if len(file_types) == 0: Logger.log("e", "There are no file types available to write with!") raise OutputDeviceError.WriteRequestFailedError(catalog.i18nc("@info:warning", "There are no file types available to write with!")) # Find the first available preferred mime type preferred_mimetype = None for mime_type in preferred_mimetype_list: if any(ft["mime_type"] == mime_type for ft in file_types): preferred_mimetype = mime_type break extension_added = False for item in file_types: type_filter = "{0} (*.{1})".format(item["description"], item["extension"]) filters.append(type_filter) mime_types.append(item["mime_type"]) if preferred_mimetype == item["mime_type"]: selected_filter = type_filter if file_name and not extension_added: extension_added = True file_name += "." + item["extension"] # CURA-6411: This code needs to be before dialog.selectFile and the filters, because otherwise in macOS (for some reason) the setDirectory call doesn't work. stored_directory = Application.getInstance().getPreferences().getValue("local_file/dialog_save_path") if stored_directory and stored_directory != "" and os.path.exists(stored_directory): dialog.setDirectory(stored_directory) # Add the file name before adding the extension to the dialog if file_name is not None: dialog.selectFile(file_name) dialog.setNameFilters(filters) if selected_filter is not None: dialog.selectNameFilter(selected_filter) if not dialog.exec(): raise OutputDeviceError.UserCanceledError() save_path = dialog.directory().absolutePath() Application.getInstance().getPreferences().setValue("local_file/dialog_save_path", save_path) selected_type = file_types[filters.index(dialog.selectedNameFilter())] Application.getInstance().getPreferences().setValue("local_file/last_used_type", selected_type["mime_type"]) # Get file name from file dialog file_name = dialog.selectedFiles()[0] Logger.log("d", "Writing to [%s]..." % file_name) if os.path.exists(file_name): result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"), catalog.i18nc("@label Don't translate the XML tag !", "The file {0} already exists. Are you sure you want to overwrite it?").format(file_name)) if result == QMessageBox.StandardButton.No: raise OutputDeviceError.UserCanceledError() silent_save = kwargs.get("silent_save", False) writer_args = kwargs.get("writer_args", {}) self._performWrite(file_name, selected_type, file_handler, nodes, silent_save, writer_args) def _performWrite(self, file_name, selected_type, file_handler, nodes, silent_save, writer_args): """Writes the specified nodes to a file. This is split from requestWrite to allow interception in other plugins. See Ultimaker/Cura#10917. :param file_name: File path to write to. :param selected_type: Selected file type to write. :param file_handler: File handler for writing to the file. :param nodes: A collection of scene nodes that should be written to the :param silent_save: When true, ignore all side effects (set project name, add recent file, ...) :param writer_args: Extra list of arguments to be given to the writer file. """ # Actually writing file if file_handler: file_writer = file_handler.getWriter(selected_type["id"]) else: file_writer = Application.getInstance().getMeshFileHandler().getWriter(selected_type["id"]) if isinstance(file_writer, WorkspaceWriter) and not silent_save: self.setLastOutputName(file_name) self.writeStarted.emit(self) try: mode = selected_type["mode"] if mode == MeshWriter.OutputMode.TextMode: Logger.log("d", "Writing to Local File %s in text mode", file_name) stream = open(file_name, "wt", encoding = "utf-8") elif mode == MeshWriter.OutputMode.BinaryMode: Logger.log("d", "Writing to Local File %s in binary mode", file_name) stream = open(file_name, "wb") else: Logger.log("e", "Unrecognised OutputMode.") return None writer_args["mime_type"] = selected_type["mime_type"] job = WriteFileJob(file_writer, stream, nodes, mode, writer_args) job.setFileName(file_name) if not silent_save: job.setAddToRecentFiles(True) # The file will be added into the "recent files" list upon success job.progress.connect(self._onJobProgress) job.finished.connect(self._onWriteJobFinished) message = Message(catalog.i18nc("@info:progress Don't translate the XML tags !", "Saving to {0}").format(file_name), 0, False, -1 , catalog.i18nc("@info:title", "Saving")) message.show() job.setMessage(message) self._writing = True job.start() except PermissionError as e: Logger.log("e", "Permission denied when trying to write to %s: %s", file_name, str(e)) raise OutputDeviceError.PermissionDeniedError(catalog.i18nc("@info:status Don't translate the XML tags !", "Permission denied when trying to save {0}").format(file_name)) from e except OSError as e: Logger.log("e", "Operating system would not let us write to %s: %s", file_name, str(e)) raise OutputDeviceError.WriteRequestFailedError(catalog.i18nc("@info:status Don't translate the XML tags or !", "Could not save to {0}: {1}").format(file_name, str(e))) from e def _onJobProgress(self, job, progress): self.writeProgress.emit(self, progress) def _onWriteJobFinished(self, job): if job.getStream(): error = job.getError() try: # Explicitly closing the stream flushes the write-buffer job.getStream().close() except Exception as e: if not error: # Only log new error if there was no previous one error = e self._writing = False self.writeFinished.emit(self) if not error: message = Message( catalog.i18nc("@info:status Don't translate the XML tags !", "Saved to {0}").format(job.getFileName()), title=catalog.i18nc("@info:title", "File Saved"), message_type=Message.MessageType.POSITIVE) message.addAction("open_folder", catalog.i18nc("@action:button", "Open Folder"), "open-folder", catalog.i18nc("@info:tooltip", "Open the folder containing the file")) message._folder = os.path.dirname(job.getFileName()) message.actionTriggered.connect(self._onMessageActionTriggered) message.show() self.writeSuccess.emit(self) else: try: os.remove(job.getFileName()) except Exception as e: Logger.logException("e", "Exception when trying to remove incomplete exported file %s", str(job.getFileName())) message = Message(catalog.i18nc("@info:status Don't translate the XML tags or !", "Could not save to {0}: {1}").format(job.getFileName(), str(error)), title=catalog.i18nc("@info:title", "Error"), message_type=Message.MessageType.ERROR) message.show() self.writeError.emit(self) def _onMessageActionTriggered(self, message, action): if action == "open_folder" and hasattr(message, "_folder"): QDesktopServices.openUrl(QUrl.fromLocalFile(message._folder))