From 0159b91c69e9e0e9dcbe50ff52932b75d64a0065 Mon Sep 17 00:00:00 2001 From: Martin Tranberg Date: Tue, 7 Apr 2026 12:30:33 +0200 Subject: [PATCH] feat: add status bar gauge and implement pagination for site and folder fetching --- sharepoint_browser.py | 346 ++++++++++++++++++++++-------------------- 1 file changed, 180 insertions(+), 166 deletions(-) diff --git a/sharepoint_browser.py b/sharepoint_browser.py index 9735318..9910080 100644 --- a/sharepoint_browser.py +++ b/sharepoint_browser.py @@ -904,8 +904,14 @@ class SharePointApp(wx.Frame): self.refresh_fav_list() # 4. STATUS BAR - self.status_bar = self.CreateStatusBar() - self.status_bar.SetStatusText(self.get_txt("status_ready")) + self.status_bar = self.CreateStatusBar(2) + self.status_bar.SetStatusWidths([-1, 150]) # Text field and Gauge field + self.status_bar.SetStatusText(self.get_txt("status_ready"), 0) + + # Add a Gauge to the status bar + self.gauge = wx.Gauge(self.status_bar, range=100, size=(140, 18), style=wx.GA_HORIZONTAL | wx.GA_SMOOTH) + self.gauge.Hide() + self.Bind(wx.EVT_SIZE, self.on_status_bar_resize) panel.SetSizer(vbox) self.Bind(wx.EVT_SIZE, self.on_resize) @@ -1315,21 +1321,39 @@ class SharePointApp(wx.Frame): os.makedirs(local_dir) url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - items = res.json().get('value', []) - for item in items: - item_path = os.path.join(local_dir, item['name']) - if 'folder' in item: - self._download_folder_recursive_sync(drive_id, item['id'], item_path) - else: - self._download_file_sync_call(drive_id, item['id'], item_path) + while url: + res = requests.get(url, headers=self.headers) + if res.status_code == 200: + res_data = res.json() + items = res_data.get('value', []) + for item in items: + item_path = os.path.join(local_dir, item['name']) + if 'folder' in item: + self._download_folder_recursive_sync(drive_id, item['id'], item_path) + else: + self._download_file_sync_call(drive_id, item['id'], item_path) + url = res_data.get('@odata.nextLink') + else: + break - def set_status(self, text): - wx.CallAfter(self.status_bar.SetStatusText, text) + def set_status(self, text, field=0): + wx.CallAfter(self.status_bar.SetStatusText, text, field) + + def pulse_gauge(self, start=True): + def _do(): + if not self: return + if start: + self.gauge.Show() + self.gauge.Pulse() + else: + self.gauge.Hide() + self.gauge.SetValue(0) + self.Layout() + wx.CallAfter(_do) def show_info(self, text, type=wx.ICON_INFORMATION, auto_hide=True): def _do(): + if not self: return self.info_bar.Dismiss() self.info_bar.ShowMessage(text, type) if auto_hide: @@ -1504,6 +1528,13 @@ class SharePointApp(wx.Frame): event.Skip() + def on_status_bar_resize(self, event): + if hasattr(self, 'gauge') and self.gauge: + rect = self.status_bar.GetFieldRect(1) + self.gauge.SetPosition((rect.x + 5, rect.y + 2)) + self.gauge.SetSize((rect.width - 10, rect.height - 4)) + event.Skip() + def _update_button_labels(self, full=True): if not self: return try: @@ -1629,7 +1660,6 @@ class SharePointApp(wx.Frame): self.access_token = result["access_token"] self.headers = {'Authorization': f'Bearer {self.access_token}'} self.login_btn.Disable() - # self.login_btn.Hide() # Valgfrit: Skjul login knap helt når vi er inde self.login_btn.SetLabel(self.get_txt("btn_logged_in") if not getattr(self, "compact_mode", False) else "") self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå self.home_btn.Enable() @@ -1657,14 +1687,27 @@ class SharePointApp(wx.Frame): def _fetch_sites_bg(self): if not self.ensure_valid_token(): return + self.pulse_gauge(True) + all_sites = [] url = "https://graph.microsoft.com/v1.0/sites?search=*" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - sites = res.json().get('value', []) - sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower()) - wx.CallAfter(self._populate_sites_tree, sites) + + while url: + res = requests.get(url, headers=self.headers) + if res.status_code == 200: + data = res.json() + all_sites.extend(data.get('value', [])) + url = data.get('@odata.nextLink') + self.set_status(f"{self.get_txt('status_fetching_sites')} ({len(all_sites)}...)") + self.pulse_gauge(True) + else: + break + + if all_sites: + all_sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower()) + wx.CallAfter(self._populate_sites_tree, all_sites) else: self.set_status(self.get_txt("msg_unknown_error")) + self.pulse_gauge(False) def _populate_sites_tree(self, sites): self.set_status(f"{len(sites)} sites.") @@ -1678,102 +1721,15 @@ class SharePointApp(wx.Frame): } self.tree_ctrl.SetItemHasChildren(node, True) - # Select the first site or just show in list (defaulting to showing root sites in list) + # Root sites in list view self.list_ctrl.DeleteAllItems() self.current_items = [] for i, site in enumerate(sites): name = site.get('displayName', site.get('name')) self.list_ctrl.InsertItem(i, name, self.idx_site) self.list_ctrl.SetItem(i, 1, self.get_txt("type_site")) - self.list_ctrl.SetItem(i, 2, "") # Størrelse - self.list_ctrl.SetItem(i, 3, "") # Sidst ændret - self.current_items.append({ - "type": "SITE", "id": site['id'], "name": name, - "size": None, "modified": "", "web_url": site.get('webUrl') - }) - - def on_tree_expanding(self, event): - item = event.GetItem() - data = self.tree_item_data.get(item) - if not data or data.get("loaded"): - return - - loading_node = self.tree_ctrl.AppendItem(item, self.get_txt("status_loading")) - threading.Thread(target=self._fetch_tree_children_bg, args=(item, data), daemon=True).start() - - def _fetch_tree_children_bg(self, parent_node, data): - if not self.ensure_valid_token(): return - if data['type'] == "SITE": - url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - drives = res.json().get('value', []) - drives.sort(key=lambda x: x.get('name', '').lower()) - wx.CallAfter(self._populate_tree_drives, parent_node, drives, data) - elif data['type'] == "DRIVE": - url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - items = res.json().get('value', []) - folders = [x for x in items if 'folder' in x] - folders.sort(key=lambda x: x['name'].lower()) - wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) - elif data['type'] == "FOLDER": - url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - items = res.json().get('value', []) - folders = [x for x in items if 'folder' in x] - folders.sort(key=lambda x: x['name'].lower()) - wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) - - def load_sites(self, event=None): - self.set_status(self.get_txt("status_fetching_sites")) - self.tree_ctrl.DeleteAllItems() - self.list_ctrl.DeleteAllItems() - self.current_items = [] - self.tree_item_data = {} - self._pending_tree_selection_id = None - - self.tree_root = self.tree_ctrl.AddRoot("HiddenRoot") - self.current_path = ["SharePoint"] - self.history = [] - self.update_path_display() - - threading.Thread(target=self._fetch_sites_bg, daemon=True).start() - - def _fetch_sites_bg(self): - if not self.ensure_valid_token(): return - url = "https://graph.microsoft.com/v1.0/sites?search=*" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - sites = res.json().get('value', []) - sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower()) - wx.CallAfter(self._populate_sites_tree, sites) - else: - self.set_status(self.get_txt("msg_unknown_error")) - - def _populate_sites_tree(self, sites): - self.set_status(f"{len(sites)} sites.") - for site in sites: - name = site.get('displayName', site.get('name')) - node = self.tree_ctrl.AppendItem(self.tree_root, name, image=self.idx_site) - self.tree_item_data[node] = { - "type": "SITE", "id": site['id'], "name": name, - "drive_id": None, "path": ["SharePoint", name], "loaded": False, - "web_url": site.get('webUrl') - } - self.tree_ctrl.SetItemHasChildren(node, True) - - # Select the first site or just show in list (defaulting to showing root sites in list) - self.list_ctrl.DeleteAllItems() - self.current_items = [] - for i, site in enumerate(sites): - name = site.get('displayName', site.get('name')) - self.list_ctrl.InsertItem(i, name, self.idx_site) - self.list_ctrl.SetItem(i, 1, self.get_txt("type_site")) - self.list_ctrl.SetItem(i, 2, "") # Størrelse - self.list_ctrl.SetItem(i, 3, "") # Sidst ændret + self.list_ctrl.SetItem(i, 2, "") + self.list_ctrl.SetItem(i, 3, "") self.current_items.append({ "type": "SITE", "id": site['id'], "name": name, "size": None, "modified": "", "web_url": site.get('webUrl'), @@ -1791,29 +1747,34 @@ class SharePointApp(wx.Frame): def _fetch_tree_children_bg(self, parent_node, data): if not self.ensure_valid_token(): return + self.pulse_gauge(True) + all_children = [] + if data['type'] == "SITE": url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - drives = res.json().get('value', []) - drives.sort(key=lambda x: x.get('name', '').lower()) - wx.CallAfter(self._populate_tree_drives, parent_node, drives, data) elif data['type'] == "DRIVE": url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children" - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - items = res.json().get('value', []) - folders = [x for x in items if 'folder' in x] - folders.sort(key=lambda x: x['name'].lower()) - wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) elif data['type'] == "FOLDER": url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children" + + while url: res = requests.get(url, headers=self.headers) if res.status_code == 200: - items = res.json().get('value', []) - folders = [x for x in items if 'folder' in x] - folders.sort(key=lambda x: x['name'].lower()) - wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) + res_data = res.json() + all_children.extend(res_data.get('value', [])) + url = res_data.get('@odata.nextLink') + self.pulse_gauge(True) + else: + break + + if data['type'] == "SITE": + all_children.sort(key=lambda x: x.get('name', '').lower()) + wx.CallAfter(self._populate_tree_drives, parent_node, all_children, data) + else: + folders = [x for x in all_children if 'folder' in x] + folders.sort(key=lambda x: x['name'].lower()) + wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) + self.pulse_gauge(False) def _populate_tree_drives(self, parent_node, drives, parent_data): self.tree_ctrl.DeleteChildren(parent_node) @@ -1907,36 +1868,40 @@ class SharePointApp(wx.Frame): def _fetch_list_contents_bg(self, data): if not self.ensure_valid_token(): return + self.pulse_gauge(True) items_data = [] + if data['type'] == "SITE": url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives" + elif data['type'] == "DRIVE": + url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children" + else: # FOLDER + url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children" + + first_chunk = True + while url: res = requests.get(url, headers=self.headers) - if res.status_code == 200: - drives = res.json().get('value', []) - drives.sort(key=lambda x: x.get('name', '').lower()) - for drive in drives: - name = drive.get('name', '') - items_data.append({ - "type": "DRIVE", "id": drive['id'], "name": name, - "drive_id": drive['id'], "modified": "", "size": None, - "web_url": drive.get('webUrl'), + if res.status_code != 200: break + + res_data = res.json() + raw_items = res_data.get('value', []) + + # Map items + chunk_data = [] + drive_id = data.get('drive_id') if data['type'] != "SITE" else None + for item in raw_items: + if data['type'] == "SITE": + name = item.get('name', '') + chunk_data.append({ + "type": "DRIVE", "id": item['id'], "name": name, + "drive_id": item['id'], "modified": "", "size": None, + "web_url": item.get('webUrl'), "path": data['path'] + [name] }) - elif data['type'] in ["DRIVE", "FOLDER"]: - drive_id = data['drive_id'] - if data['type'] == "DRIVE": - url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children" - else: - url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{data['id']}/children" - - res = requests.get(url, headers=self.headers) - if res.status_code == 200: - items = res.json().get('value', []) - items.sort(key=lambda x: (not 'folder' in x, x['name'].lower())) - for item in items: + else: is_folder = 'folder' in item modified = item.get('lastModifiedDateTime', '').replace('T', ' ').split('.')[0] - items_data.append({ + chunk_data.append({ "type": "FOLDER" if is_folder else "FILE", "id": item['id'], "name": item['name'], "drive_id": drive_id, "modified": modified, @@ -1945,8 +1910,50 @@ class SharePointApp(wx.Frame): "hash": item.get('file', {}).get('hashes', {}).get('quickXorHash') if not is_folder else None, "path": data['path'] + [item['name']] }) - - wx.CallAfter(self._populate_list_ctrl, items_data, data) + + items_data.extend(chunk_data) + self.set_status(f"Henter... ({len(items_data)} emner)") + self.pulse_gauge(True) + + # Chunked UI Update + if first_chunk: + wx.CallAfter(self._populate_list_ctrl, chunk_data, data, finalize=False) + first_chunk = False + else: + wx.CallAfter(self._append_list_items, chunk_data) + + url = res_data.get('@odata.nextLink') + + # Finalize + wx.CallAfter(self._finalize_list_loading, items_data) + self.pulse_gauge(False) + + def _append_list_items(self, items): + if not self: return + start_idx = len(self.current_items) + self.current_items.extend(items) + + for i, item in enumerate(items): + idx = start_idx + i + img_idx = self.idx_file + if item['type'] == "FOLDER": img_idx = self.idx_folder + elif item['type'] == "DRIVE": img_idx = self.idx_drive + elif item['type'] == "SITE": img_idx = self.idx_site + elif item['type'] == "FILE": + img_idx = self.get_icon_idx_for_file(item['name']) + + self.list_ctrl.InsertItem(idx, item['name'], img_idx) + type_str = self.get_txt("type_folder") if item['type'] == "FOLDER" else self.get_txt("type_file") if item['type'] == "FILE" else self.get_txt("type_drive") + self.list_ctrl.SetItem(idx, 1, type_str) + size_str = format_size(item['size']) if item['size'] is not None else "" + self.list_ctrl.SetItem(idx, 2, size_str) + self.list_ctrl.SetItem(idx, 3, item['modified']) + + def _finalize_list_loading(self, items_data): + if not self: return + self.current_items = items_data + self.apply_sorting() + self.set_status(self.get_txt("status_ready")) def on_column_click(self, event): col = event.GetColumn() @@ -2076,30 +2083,37 @@ class SharePointApp(wx.Frame): info.SetImage(-1) self.list_ctrl.SetColumn(col, info) - def _populate_list_ctrl(self, items_data, parent_data): + def _populate_list_ctrl(self, items_data, parent_data, finalize=True): if not self: return try: self.current_items = items_data - # Anvend sortering før visning - self.apply_sorting() + if finalize: + # Anvend sortering før visning + self.apply_sorting() + else: + # Bare vis de nuværende usorteret (for hurtigere feedback) + self._update_list_view_only() - # Opdater tilstand - self.current_web_url = parent_data.get('web_url') - - if parent_data['type'] == "SITE": - self.current_site_id = parent_data['id'] - elif parent_data['type'] == "DRIVE": - self.current_drive_id = parent_data['id'] - self.current_folder_id = "root" - elif parent_data['type'] == "FOLDER": - self.current_drive_id = parent_data['drive_id'] - self.current_folder_id = parent_data['id'] + # Opdater tilstand (kun første gang) + if parent_data: + self.current_web_url = parent_data.get('web_url') - # Opdater knap-synlighed - can_upload = self.current_drive_id is not None - wx.CallAfter(lambda: self._safe_update_buttons(can_upload)) - self.set_status(self.get_txt("status_ready")) + if parent_data['type'] == "SITE": + self.current_site_id = parent_data['id'] + elif parent_data['type'] == "DRIVE": + self.current_drive_id = parent_data['id'] + self.current_folder_id = "root" + elif parent_data['type'] == "FOLDER": + self.current_drive_id = parent_data['drive_id'] + self.current_folder_id = parent_data['id'] + + # Opdater knap-synlighed + can_upload = self.current_drive_id is not None + wx.CallAfter(lambda: self._safe_update_buttons(can_upload)) + + if finalize: + self.set_status(self.get_txt("status_ready")) except RuntimeError: pass