1
0
mirror of https://github.com/bpozdena/OneDriveGUI.git synced 2026-10-24 04:59:17 +03:00

Add error handling and notifications for failed file syncs

This commit is contained in:
Boris Pozdena
2026-10-08 18:00:08 +02:00
parent 8a781a7e18
commit d111f0dcd3
2 changed files with 413 additions and 112 deletions

View File

@@ -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 <a href=file:///{absolute_path}>{shortened_path}</a>")
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 - <a href=file:///{absolute_path}>{shortened_path}</a>")
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 <a href=file:///{absolute_path}>{shortened_path}</a>")
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 <a href=file:///{absolute_path}>{shortened_path}</a>")
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 <a href=file:///{absolute_path}>{shortened_path}</a>")
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 - <a href=file:///{absolute_path}>{shortened_path}</a>")
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 <a href=file:///{absolute_path}>{shortened_path}</a>")
@@ -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 - <a href=file:///{parent_dir.replace(' ', '%20')}>{shortened_path}</a>")
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.

View File

@@ -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)