You've already forked OneDriveGUI
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user