diff --git a/src/onedrive_gui/main_window.py b/src/onedrive_gui/main_window.py index 657938f..6154be6 100644 --- a/src/onedrive_gui/main_window.py +++ b/src/onedrive_gui/main_window.py @@ -84,6 +84,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.setWindowTitle(f"OneDriveGUI v{version}") self.setWindowIcon(QIcon(DIR_PATH + "/resources/images/icons8-cloud-80.png")) self.trash_icon = QApplication.style().standardIcon(QStyle.SP_TrashIcon) + self.fail_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxWarning) if gui_settings.get("frameless_window") == "True": self.setWindowFlags(Qt.FramelessWindowHint) @@ -744,6 +745,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): workers[profile_name].update_progress_new.connect(self.event_update_progress) workers[profile_name].update_profile_status.connect(self.event_update_profile_status) workers[profile_name].clear_warning.connect(self.clear_warning_handler) + workers[profile_name].finalize_stalled_tasks.connect(self.event_finalize_stalled_tasks) + workers[profile_name].notify_failed_sync.connect(self.show_failed_sync_notification) workers[profile_name].started.connect(lambda: logging.info(f"started worker {profile_name}")) workers[profile_name].finished.connect(lambda: logging.info(f"finished worker {profile_name}")) try: @@ -760,6 +763,17 @@ class MainWindow(QMainWindow, Ui_MainWindow): 10000, ) + def show_failed_sync_notification(self, profile_name, count): + """Show a tray balloon when files fail to sync, so the user is informed + even when the GUI window is minimized.""" + if self.tray: + self.tray.showMessage( + "OneDriveGUI - Sync completed with errors", + f"'{profile_name}': {count} file(s) failed to sync.\nHover the failed entries in the file operation list for details.", + QSystemTrayIcon.Warning, + 10000, + ) + def resync_auth_dialog(self, profile_name): resync_question = QMessageBox( QMessageBox.Question, @@ -864,7 +878,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): "file_operation": file_operation.group(1), "file_path": file_path.group(1), "progress": progress.group(1), - "transfer_complete": transfer_complete + "transfer_complete": transfer_complete, + "transfer_failed": transfer_failed } """ _sync_dir = os.path.expanduser(global_config[profile]["onedrive"]["sync_dir"].strip('"')) @@ -873,7 +888,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): file_path = f"{_sync_dir}" + "/" + data["file_path"] absolute_path = QFileInfo(file_path).absolutePath().replace(" ", "%20") relative_path_display = os.path.relpath(QFileInfo(file_path).absolutePath(), _sync_dir + os.path.sep) - parent_dir = re.search(r".+/([^/]+)/.+$", file_path).group(1) + parent_dir_match = re.search(r".+/([^/]+)/.+$", file_path) + parent_dir = parent_dir_match.group(1) if parent_dir_match else "" file_size = QFileInfo(file_path + ".partial").size() if QFileInfo(file_path).size() == 0 else QFileInfo(file_path).size() file_size_human = humanize_file_size(file_size) file_name = QFileInfo(file_path).fileName() @@ -882,94 +898,142 @@ class MainWindow(QMainWindow, Ui_MainWindow): progress_data_human = humanize_file_size(progress_data) file_operation = data["file_operation"] transfer_complete = data["transfer_complete"] + transfer_failed = data.get("transfer_failed", False) move_scrollbar = True new_list_item = True + if transfer_failed: + item_state = "Failed" + elif transfer_complete or file_operation in ("Deleting", "Moving"): + item_state = "Complete" + else: + item_state = file_operation + + # Find an existing row for the same file to update. Matching is done on the + # full path (not just the file name) so that identically-named files in + # different folders each keep their own row. If the file is already in the + # list and its progress bar is not complete, update the existing item; + # otherwise skip and create a new item. + item = None for row in range(50): # Check last 50 entries to see if the file is already in the list. - if self.profile_status_pages[profile].listWidget.item(row) != None: - item = self.profile_status_pages[profile].listWidget.item(row) - item_widget = self.profile_status_pages[profile].listWidget.itemWidget(item) - item_file_name = item_widget.get_file_name() - item_incomplete = item_widget.get_timestamp_text() == "" + candidate = self.profile_status_pages[profile].listWidget.item(row) + if candidate is None: + continue + candidate_widget = self.profile_status_pages[profile].listWidget.itemWidget(candidate) + if candidate_widget.get_file_path() == file_path and candidate_widget.get_timestamp_text() == "": + item = candidate + break - if file_name == item_file_name and item_incomplete: - # If current file is already in the list and progress bar is not complete, update the existing item. - # Otherwise skip and create a new item. - logging.debug("Updating list item") + if item is not None: + item_widget = self.profile_status_pages[profile].listWidget.itemWidget(item) + item_file_name = item_widget.get_file_name() + previous_state = item_widget.get_state() + logging.debug("Updating list item") - item_widget.set_progress(int(progress)) - item_widget.set_icon(file_path) - item_widget.hide_progress_bar(transfer_complete) + item_widget.set_file_path(file_path) + item_widget.set_state(item_state) + item_widget.set_progress(int(progress)) + item_widget.set_icon(file_path) + item_widget.hide_progress_bar(transfer_complete or transfer_failed) - if file_operation == "Deleting": - item_widget.set_label_1(f"Deleted from {parent_dir}") - item_widget.set_custom_icon(self.trash_icon) - item_widget.set_label_2(f"") - # Store timestamp and display for deleted files - if "timestamp" in data and data["timestamp"]: - item_widget.set_completion_timestamp(data["timestamp"]) - relative_time = format_relative_time(data["timestamp"]) - item_widget.set_timestamp(relative_time) - else: - item_widget.set_timestamp("") + if file_operation == "Deleting": + item_widget.set_label_1(f"Deleted from {parent_dir}") + item_widget.set_custom_icon(self.trash_icon) + item_widget.set_label_2(f"") + # Store timestamp and display for deleted files + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") - elif file_operation == "Moving": - item_widget.set_label_1(f"Trashed from {parent_dir}") - item_widget.set_label_2(f"") - # Store timestamp and display for moved files - if "timestamp" in data and data["timestamp"]: - item_widget.set_completion_timestamp(data["timestamp"]) - relative_time = format_relative_time(data["timestamp"]) - item_widget.set_timestamp(relative_time) - else: - item_widget.set_timestamp("") + elif file_operation == "Moving": + item_widget.set_label_1(f"Trashed from {parent_dir}") + item_widget.set_label_2(f"") + # Store timestamp and display for moved files + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") - elif transfer_complete and file_operation == "Uploading": - shortened_path = shorten_path(relative_path_display, 32) - item_widget.set_label_1(f"Uploaded from {shortened_path}") - item_widget.set_label_2(f"{file_size_human}") - # Store timestamp and display in separate timestamp label - if "timestamp" in data and data["timestamp"]: - item_widget.set_completion_timestamp(data["timestamp"]) - relative_time = format_relative_time(data["timestamp"]) - item_widget.set_timestamp(relative_time) - else: - item_widget.set_timestamp("") + elif transfer_failed: + # The transfer failed - mark the item as failed instead of + # leaving it in a perpetual "uploading" state. The path links + # to the file's folder so the user can quickly locate and fix + # the file; hovering shows the full path. The timestamp + # is deliberately left empty so repeated attempts of the + # same file update this row rather than adding duplicates, + # and a later successful upload flips it back to "Uploaded". + failed_operation = "Upload" if file_operation == "Uploading" else "Download" + shortened_path = shorten_path(relative_path_display, 32) + item_widget.set_label_1(f"{failed_operation} failed - {shortened_path}") + item_widget.set_label_2(f"") + item_widget.set_custom_icon(self.fail_icon) + item_widget.set_timestamp("") + item_widget.set_tooltip(self._failed_file_tooltip(file_path, data.get("error_message"))) - elif transfer_complete and file_operation == "Downloading": - shortened_path = shorten_path(relative_path_display, 32) - item_widget.set_label_1(f"Downloaded from {shortened_path}") - item_widget.set_label_2(f"{file_size_human}") - # Store timestamp and display in separate timestamp label - if "timestamp" in data and data["timestamp"]: - item_widget.set_completion_timestamp(data["timestamp"]) - relative_time = format_relative_time(data["timestamp"]) - item_widget.set_timestamp(relative_time) - else: - item_widget.set_timestamp("") + elif transfer_complete and file_operation == "Uploading": + shortened_path = shorten_path(relative_path_display, 32) + item_widget.set_label_1(f"Uploaded from {shortened_path}") + item_widget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") - elif file_operation == "Downloading": - # Estimate final size of file before download completes - # Adding 5% to progress as the OD client report status 5% behind. - item_widget.set_label_1(file_operation) - item_widget.set_label_2(f"{humanize_file_size(file_size)} of ~{humanize_file_size(int(file_size) / (int(progress) + 5) * 100)}") - else: - item_widget.set_label_1(file_operation) - item_widget.set_label_2(f"{progress_data_human} of {file_size_human}") + elif transfer_complete and file_operation == "Downloading": + shortened_path = shorten_path(relative_path_display, 32) + item_widget.set_label_1(f"Downloaded from {shortened_path}") + item_widget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") - self.profile_status_pages[profile].listWidget.setItemWidget(item, item_widget) - new_list_item = False - logging.debug(f"List item updated for file {item_file_name}") - break + elif file_operation == "Downloading": + # Estimate final size of file before download completes + # Adding 5% to progress as the OD client report status 5% behind. + item_widget.set_label_1(file_operation) + item_widget.set_label_2(f"{humanize_file_size(file_size)} of ~{humanize_file_size(int(file_size) / (int(progress) + 5) * 100)}") + else: + item_widget.set_label_1(file_operation) + item_widget.set_label_2(f"{progress_data_human} of {file_size_human}") + + listWidget = self.profile_status_pages[profile].listWidget + if transfer_failed and listWidget.row(item) != 0: + # Move the failed entry to the top of the list so it stays + # visible and does not scroll away below synced files. + self._move_list_item(listWidget, item, 0) + elif previous_state == "Failed" and item_state != "Failed": + # A previously failed entry completed (e.g. the user fixed the + # file) - move it below the block of failed entries pinned at + # the top, so the remaining failures stay visible. + target_row = self._first_row_below_failed_block(listWidget, exclude_item=item) + self._move_list_item(listWidget, item, target_row) + else: + listWidget.setItemWidget(item, item_widget) + new_list_item = False + logging.debug(f"List item updated for file {item_file_name}") if new_list_item: logging.debug(f"Adding new list item for file {file_name}") myQCustomQWidget = TaskList() myQCustomQWidget.set_file_name(file_name) + myQCustomQWidget.set_file_path(file_path) + myQCustomQWidget.set_state(item_state) myQCustomQWidget.set_progress(int(progress)) myQCustomQWidget.set_icon(file_path) - myQCustomQWidget.hide_progress_bar(transfer_complete) + myQCustomQWidget.hide_progress_bar(transfer_complete or transfer_failed) if file_operation == "Deleting": myQCustomQWidget.set_label_1(f"Deleted from {parent_dir}") @@ -994,6 +1058,16 @@ class MainWindow(QMainWindow, Ui_MainWindow): else: myQCustomQWidget.set_timestamp("") + elif transfer_failed: + # The transfer failed before an item existed - create it in the failed + # state so the failure is visible in the file operation list. + failed_operation = "Upload" if file_operation == "Uploading" else "Download" + shortened_path = shorten_path(relative_path_display, 32) + myQCustomQWidget.set_label_1(f"{failed_operation} failed - {shortened_path}") + myQCustomQWidget.set_label_2(f"") + myQCustomQWidget.set_custom_icon(self.fail_icon) + myQCustomQWidget.set_tooltip(self._failed_file_tooltip(file_path, data.get("error_message"))) + elif transfer_complete and file_operation == "Uploading": shortened_path = shorten_path(relative_path_display, 32) myQCustomQWidget.set_label_1(f"Uploaded from {shortened_path}") @@ -1033,9 +1107,12 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Set size hint myQListWidgetItem.setSizeHint(myQCustomQWidget.sizeHint()) - # Add QListWidgetItem into QListWidget + # Add QListWidgetItem into QListWidget. Failed transfers are pinned + # at the top so they stay visible; all other entries are inserted + # below the block of failed entries. listWidget = self.profile_status_pages[profile].listWidget - listWidget.insertItem(0, myQListWidgetItem) + insert_row = 0 if transfer_failed else self._first_row_below_failed_block(listWidget) + listWidget.insertItem(insert_row, myQListWidgetItem) listWidget.setItemWidget(myQListWidgetItem, myQCustomQWidget) # If the list is not scrolled all the way to the top, increment the scroll value so that the list stays in the same spot when new items are added. @@ -1048,6 +1125,104 @@ class MainWindow(QMainWindow, Ui_MainWindow): if listWidget.count() > 10_000: listWidget.takeItem(listWidget.count() - 1) + @staticmethod + def _failed_file_tooltip(file_path, error_message): + """Build hover text for a failed transfer: full file path and, when known, + the error message explaining why the file failed to sync.""" + tooltip = QFileInfo(file_path).absoluteFilePath() + if error_message: + # Keep the tooltip readable even for very long API error messages. + error_message = error_message.strip() + if len(error_message) > 500: + error_message = error_message[:500] + "..." + tooltip += f"\n\nError: {error_message}" + return tooltip + + @staticmethod + def _move_list_item(list_widget, item, target_row): + """ + Move an item to the given row within the list. Qt deletes a row's widget + when the row is taken out of a QListWidget, so the widget is cloned + before the move and the row gets the fresh clone - reusing the old + widget would leave a dangling pointer in the view and crash on repaint. + """ + item_widget = list_widget.itemWidget(item) + if item_widget is None: + return + new_widget = item_widget.clone() + row = list_widget.row(item) + list_widget.takeItem(row) + if row < target_row: + target_row -= 1 + target_row = max(0, min(target_row, list_widget.count())) + list_widget.insertItem(target_row, item) + list_widget.setItemWidget(item, new_widget) + + @staticmethod + def _first_row_below_failed_block(list_widget, exclude_item=None): + """ + Index of the first row below the block of failed entries pinned at the + top of the list. `exclude_item` is skipped when scanning (used when the + row being relocated is itself non-failed and sits inside the block). + """ + for row in range(list_widget.count()): + item = list_widget.item(row) + if exclude_item is not None and item == exclude_item: + continue + row_widget = list_widget.itemWidget(item) + if row_widget is None or row_widget.get_state() != "Failed": + return row + return list_widget.count() + + def event_finalize_stalled_tasks(self, profile): + """ + Mark file transfers that never reported completion during a sync cycle as + failed, so they do not stay in a perpetual 'uploading' state in the list. + Called at sync cycle boundaries (start and end of each cycle). + """ + if profile not in self.profile_status_pages: + return + + try: + _sync_dir = os.path.expanduser(global_config[profile]["onedrive"]["sync_dir"].strip('"')) + except KeyError: + return + + list_widget = self.profile_status_pages[profile].listWidget + + # Collect stalled rows first - moving items to the top below would + # shift the row indices while iterating. + stalled_items = [] + for row in range(list_widget.count()): + item = list_widget.item(row) + item_widget = list_widget.itemWidget(item) + if item_widget is not None and item_widget.get_state() in ("Uploading", "Downloading"): + stalled_items.append((item, item_widget)) + + for item, item_widget in stalled_items: + full_path = item_widget.get_file_path() + parent_dir = QFileInfo(full_path).absolutePath() + relative_path_display = os.path.relpath(parent_dir, _sync_dir + os.path.sep) + failed_operation = "Download" if item_widget.get_state() == "Downloading" else "Upload" + shortened_path = shorten_path(relative_path_display, 32) + + item_widget.set_state("Failed") + item_widget.hide_progress_bar(True) + item_widget.set_label_1(f"{failed_operation} failed - {shortened_path}") + item_widget.set_label_2("") + item_widget.set_custom_icon(self.fail_icon) + item_widget.set_timestamp("") + sweep_reason = "Transfer did not complete during the sync cycle (no specific error was reported)." + item_widget.set_tooltip(self._failed_file_tooltip(full_path, sweep_reason)) + # Move the failed entry to the top of the list so it stays visible. + if list_widget.row(item) != 0: + self._move_list_item(list_widget, item, 0) + else: + list_widget.setItemWidget(item, item_widget) + + if stalled_items: + logging.info(f"[{profile}] Marked {len(stalled_items)} stalled transfer(s) as failed") + def on_list_scrolled(self, value): """ Debounced scroll handler - triggers timestamp update after scrolling stops. diff --git a/src/onedrive_gui/workers.py b/src/onedrive_gui/workers.py index d8675c8..8803cd4 100644 --- a/src/onedrive_gui/workers.py +++ b/src/onedrive_gui/workers.py @@ -46,6 +46,8 @@ class WorkerThread(QThread): remove_worker = Signal(str) clear_warning = Signal(str) browser_login_required = Signal(str) + finalize_stalled_tasks = Signal(str) + notify_failed_sync = Signal(str, int) def __init__(self, profile, options=""): super(WorkerThread, self).__init__() @@ -100,6 +102,15 @@ class WorkerThread(QThread): self.pending_error = None self.pending_error_path = None + # Track per-file error messages so the GUI can show why each file failed + self.file_errors = {} + self.last_unattributed_error = None + self.last_error_time = 0.0 + + # Signature of the last notified failure list, to avoid repeating + # identical tray notifications on every sync cycle + self._last_notified_failures = None + # Track failed files for detailed error reporting self.failed_files = [] self.failed_files_count = 0 @@ -137,39 +148,24 @@ class WorkerThread(QThread): break def _emit_error_status(self, full_error_message): - """Format and emit error message with truncation and line splitting.""" - # Format for status message: max 50 chars per line, up to 3 lines (150 chars total) - max_chars_per_line = 50 - max_lines = 3 - max_total_chars = max_chars_per_line * max_lines + """Emit a short, categorized status message. The full error message is kept + for the warning icon tooltip, and per-file details are shown when hovering + the failed files in the file operation list.""" + lower = full_error_message.lower() - if len(full_error_message) > max_total_chars: - # Truncate to 150 chars total - display_text = full_error_message[: max_total_chars - 3] + "..." + if "name is too long" in lower: + short_error = "File or folder name too long" + elif "case-insensitive match" in lower: + short_error = "File name conflicts with an existing file on OneDrive" + elif "permission denied" in lower: + short_error = "File cannot be read (permission denied)" + elif "no space left" in lower or "insufficient space" in lower: + short_error = "Not enough disk space" else: - display_text = full_error_message + # Unknown error: keep the beginning of the message on a single line + short_error = full_error_message if len(full_error_message) <= 90 else full_error_message[:87] + "..." - # Split into lines of max 50 characters - lines = [] - words = display_text.split() - current_line = "" - - for word in words: - if len(current_line) + len(word) + 1 <= max_chars_per_line: - current_line += word + " " - else: - if current_line: - lines.append(current_line.strip()) - current_line = word + " " - if len(lines) >= max_lines: - break - - if current_line and len(lines) < max_lines: - lines.append(current_line.strip()) - - truncated_error = "\n".join(lines) - - self.profile_status["status_message"] = f"Error: {truncated_error}" + self.profile_status["status_message"] = f"Error: {short_error}" self.profile_status["error_message"] = full_error_message # Full error for tooltip self.update_profile_status.emit(self.profile_status, self.profile_name) @@ -199,6 +195,13 @@ class WorkerThread(QThread): self.profile_status["error_message"] = full_tooltip self.update_profile_status.emit(self.profile_status, self.profile_name) + # Raise a tray notification so the user learns about the failures even + # when the GUI window is minimized - but only when the set of failing + # files changed, so persistent failures do not spam on every cycle. + if full_tooltip != self._last_notified_failures: + self._last_notified_failures = full_tooltip + self.notify_failed_sync.emit(self.profile_name, count) + def read_stdout(self): stdout = self.onedrive_process.stdout.readline().strip() if stdout != "": @@ -269,19 +272,31 @@ class WorkerThread(QThread): "No changes or items that can be applied were discovered", ] ): - # Clear warnings when sync completes without errors - self.profile_status.pop("error_message", None) - self.clear_warning.emit(self.profile_name) - self.msg = "OneDrive sync is complete." - logging.info(f"[{self.profile_name}] {self.msg}") - self.profile_status["status_message"] = self.msg - self.update_profile_status.emit(self.profile_status, self.profile_name) + if self.failed_files or self.failed_files_count: + # This sync cycle had files that failed to sync - keep the + # "completed with errors" state (status message, warning icon + # and tray) until a cycle completes without any failures, so + # the user stays aware of the background failures. + count = self.failed_files_count if self.failed_files_count > 0 else len(self.failed_files) + logging.warning(f"[{self.profile_name}] Sync finished with {count} failed item(s) - keeping error state") + else: + # Clear warnings when sync completes without errors + self.profile_status.pop("error_message", None) + self.clear_warning.emit(self.profile_name) + self.msg = "OneDrive sync is complete." + logging.info(f"[{self.profile_name}] {self.msg}") + self.profile_status["status_message"] = self.msg + self.update_profile_status.emit(self.profile_status, self.profile_name) + # Finalize any transfers that never reported completion during the cycle. + self.finalize_stalled_tasks.emit(self.profile_name) elif "Sync with Microsoft OneDrive has completed, however there are items that failed to sync" in stdout: self.msg = "OneDrive sync completed with errors." logging.warning(f"[{self.profile_name}] {self.msg}") self.profile_status["status_message"] = self.msg self.update_profile_status.emit(self.profile_status, self.profile_name) + # Finalize any transfers that never reported completion during the cycle. + self.finalize_stalled_tasks.emit(self.profile_name) elif "Remaining Free Space" in stdout: try: @@ -315,19 +330,33 @@ class WorkerThread(QThread): self.failed_files = [] self.failed_files_count = 0 self.collecting_failed_files = False + self.file_errors = {} + self.last_unattributed_error = None + self._last_notified_failures = None self.msg = "Initializing the OneDrive API" logging.info(f"[{self.profile_name}] {self.msg}") self.profile_status["status_message"] = self.msg self.update_profile_status.emit(self.profile_status, self.profile_name) elif "Starting a sync with Microsoft OneDrive" in stdout: - # Clear warnings when a new sync cycle starts - self.profile_status.pop("error_message", None) - self.clear_warning.emit(self.profile_name) + # A new sync cycle starts. Reset the failed-files list of the + # previous cycle, but keep its warning (if any) visible until + # this cycle completes, so the user stays informed that files + # failed to sync. + previous_cycle_had_failures = bool(self.failed_files or self.failed_files_count) + self.failed_files = [] + self.failed_files_count = 0 + self.collecting_failed_files = False + if not previous_cycle_had_failures: + self.profile_status.pop("error_message", None) + self.clear_warning.emit(self.profile_name) self.msg = "Starting a sync with Microsoft OneDrive" logging.info(f"[{self.profile_name}] {self.msg}") self.profile_status["status_message"] = self.msg self.update_profile_status.emit(self.profile_status, self.profile_name) + # Finalize transfers left over from a previous cycle that never + # reported completion (e.g. the client was killed mid-sync). + self.finalize_stalled_tasks.emit(self.profile_name) elif "Processing:" in stdout or "Number of items to download from Microsoft OneDrive" in stdout or "OneDrive Client requested to create" in stdout: items_left = re.match(r"^Processing\s([0-9]+)\sOneDrive\sitems", stdout) @@ -368,6 +397,7 @@ class WorkerThread(QThread): self.file_path = re.search(r"\b[file:]+\s(.+)\s+\.\.\.", stdout) transfer_complete = any(["done" in stdout, "Deleting" in stdout, "Moving" in stdout]) + transfer_failed = "failed!" in stdout progress = "0" transfer_progress_new = { @@ -375,9 +405,22 @@ class WorkerThread(QThread): "file_path": "unknown file name" if self.file_path is None else self.file_path.group(1), "progress": progress, "transfer_complete": transfer_complete, + "transfer_failed": transfer_failed, "timestamp": datetime.now() if transfer_complete else None, } + if transfer_failed: + # Attach the reason why this file failed, if we know it. + error_message = self.file_errors.get(transfer_progress_new["file_path"]) + if error_message is None and self.last_unattributed_error and (time.time() - self.last_error_time) < 10: + # API error blocks (e.g. HTTP 400) carry no path - they are + # printed just before the client retries the same file and + # reports the failed transfer, so attribute them here. + error_message = self.last_unattributed_error + self.last_unattributed_error = None + self.file_errors[transfer_progress_new["file_path"]] = error_message + transfer_progress_new["error_message"] = error_message + # Update file transfer list logging.debug(transfer_progress_new) self.update_progress_new.emit(transfer_progress_new, self.profile_name) @@ -478,11 +521,29 @@ class WorkerThread(QThread): full_error_message = f"{self.pending_error} {additional_error}" else: full_error_message = self.pending_error + # Remember the error for the file it refers to (local file system + # error blocks include a "Path:" line), or as the most recent + # error to be attributed to the next transfer that fails. + if self.pending_error_path: + self.file_errors[self.pending_error_path] = full_error_message + self.last_unattributed_error = full_error_message + self.last_error_time = time.time() self.pending_error = None # Clear pending error self.pending_error_path = None logging.error(f"[{self.profile_name}] {full_error_message}") self._emit_error_status(full_error_message) + elif "Error Reason:" in stdout: + # API error blocks print the human-readable reason on the line after + # "Error Message:" - append it to the last error so the per-file + # tooltip explains the actual cause (e.g. file name too long), and + # re-emit the status so it can be categorized into a short message. + if self.last_unattributed_error and "Error Reason" not in self.last_unattributed_error: + reason = stdout.split("Error Reason:", 1)[1].strip() + self.last_unattributed_error = f"{self.last_unattributed_error} - {reason}" + self.last_error_time = time.time() + self._emit_error_status(self.last_unattributed_error) + elif self.pending_error and "Path:" in stdout: # Capture the affected file/folder path from a multi-line ERROR block path_parts = stdout.split("Path:", 1) @@ -500,6 +561,14 @@ class WorkerThread(QThread): self.pending_error = error_text self.pending_error_path = None + elif stdout.startswith("Skipping uploading this"): + # Examples: + # Skipping uploading this new file due to 'case-insensitive match': ./file + # Skipping uploading this file as it cannot be read (file permissions or file corruption): ./file + match = re.match(r"Skipping uploading this (?:new )?file (.*?): (.+)$", stdout) + if match: + self.file_errors[match.group(2).strip()] = f"Skipped uploading {match.group(1)}" + elif "Failed items to upload to/from Microsoft OneDrive:" in stdout: # Extract the count of failed items match = re.search(r"Failed items .+?: (\d+)", stdout) @@ -520,6 +589,21 @@ class WorkerThread(QThread): if len(self.failed_files) < 25: self.failed_files.append(failed_entry) + # Mark the matching entry in the file operation list as failed, + # or create one for files that failed before a transfer was shown + # (e.g. skipped due to permission errors or name conflicts). + transfer_progress_new = { + "file_operation": "Uploading" if operation == "upload" else "Downloading", + "file_path": file_path, + "progress": "0", + "transfer_complete": False, + "transfer_failed": True, + "error_message": self.file_errors.get(file_path), + "timestamp": None, + } + logging.debug(transfer_progress_new) + self.update_progress_new.emit(transfer_progress_new, self.profile_name) + elif "Unknown key in config file:" in stdout: # Extract the invalid config key match = re.search(r"Unknown key in config file:\s*(.+)$", stdout) @@ -746,6 +830,8 @@ class TaskList(QWidget, Ui_list_item_widget): # Store completion timestamp for relative time display self.completion_timestamp = None self._original_file_name = "" + self._file_path = "" + self.transfer_state = "Uploading" # Enable text eliding for ls_label_2 to prevent horizontal overflow self.ls_label_2.setWordWrap(False) @@ -773,6 +859,41 @@ class TaskList(QWidget, Ui_list_item_widget): def get_file_name(self): return self._original_file_name + def set_file_path(self, file_path): + """Store the absolute path of the file this row refers to, so rows for + identically-named files in different folders can be told apart.""" + self._file_path = file_path + + def get_file_path(self): + return self._file_path + + def set_state(self, state): + """Set transfer state: 'Uploading', 'Downloading', 'Complete' or 'Failed'.""" + self.transfer_state = state + + def get_state(self): + return self.transfer_state + + def clone(self): + """Create a new widget displaying the same row. + + Needed when moving a row within the list: Qt deletes a row's widget when + the row is taken out of a QListWidget, so a moved row must be given a + fresh widget instead of reusing the old one.""" + new_widget = TaskList() + new_widget.set_file_name(self._original_file_name) + new_widget.set_file_path(self._file_path) + new_widget.set_state(self.transfer_state) + new_widget.set_progress(self.ls_progressBar.value()) + new_widget.set_custom_icon(self.toolButton.icon()) + new_widget.set_label_1(self.ls_label_1.text()) + new_widget.set_label_2(self.ls_label_2.text()) + new_widget.set_completion_timestamp(self.completion_timestamp) + new_widget.set_timestamp(self.ls_label_timestamp.text()) + new_widget.set_tooltip(self.toolTip()) + new_widget.hide_progress_bar(self.ls_progressBar.isHidden()) + return new_widget + def set_progress(self, percentage): self.ls_progressBar.setValue(percentage) @@ -786,6 +907,11 @@ class TaskList(QWidget, Ui_list_item_widget): elided_text = font_metrics.elidedText(text, Qt.ElideRight, self.ls_label_2_max_width) self.ls_label_2.setText(elided_text) + def set_tooltip(self, text): + """Show text on hover anywhere on the row, e.g. the full path of a failed + file and the error message explaining why it failed.""" + self.setToolTip(text) + def set_timestamp(self, text): """Set the timestamp label text""" self.ls_label_timestamp.setText(text)