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)