From 24665c579700d6fb18d80597764ad8f9e3dbd14e Mon Sep 17 00:00:00 2001 From: Martin Tranberg Date: Mon, 4 May 2026 13:41:41 +0200 Subject: [PATCH] Add support for downloading all document libraries on a site - New get_all_drives() fetches all drives from a SharePoint site - main() loops over all drives when DOCUMENT_LIBRARY is empty, placing each library in its own subfolder under LOCAL_PATH - MSAL force_refresh now catches TypeError/ValueError for compatibility with older MSAL versions that don't support the parameter - GUI: "Download alle biblioteker" checkbox disables Library Navn and Mapper fields; load_settings restores checkbox state from config Co-Authored-By: Claude Sonnet 4.6 --- download_sharepoint.py | 45 ++++++++++++++++++++++++--------- sharepoint_gui.py | 56 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/download_sharepoint.py b/download_sharepoint.py index 6bc81ef..ccb19e7 100644 --- a/download_sharepoint.py +++ b/download_sharepoint.py @@ -175,15 +175,19 @@ def verify_integrity(local_path, remote_hash, config): def get_headers(app, force_refresh=False): scopes = ["https://graph.microsoft.com/.default"] - # If force_refresh is True, we don't rely on the cache result = None if not force_refresh: result = app.acquire_token_for_client(scopes=scopes) - + if force_refresh or not result or "access_token" not in result: logger.info("Refreshing Access Token...") - result = app.acquire_token_for_client(scopes=scopes, force_refresh=True) - + try: + result = app.acquire_token_for_client(scopes=scopes, force_refresh=True) + except (TypeError, ValueError): + # Ældre MSAL-versioner understøtter ikke force_refresh på acquire_token_for_client. + # MSAL håndterer udløbne tokens automatisk, så et nyt kald er tilstrækkeligt. + result = app.acquire_token_for_client(scopes=scopes) + if "access_token" in result: return {'Authorization': f'Bearer {result["access_token"]}'} raise Exception(f"Auth failed: {result.get('error_description')}") @@ -198,12 +202,12 @@ def get_drive_id(app, site_id, drive_name): url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives" response = safe_graph_get(app, url) drives = response.json().get('value', []) - + # Prøv præcis match for drive in drives: if drive['name'] == drive_name: return drive['id'] - + # Prøv fallback til "Documents" hvis "Delte dokumenter" fejler (SharePoint standard) if drive_name == "Delte dokumenter": for drive in drives: @@ -216,6 +220,11 @@ def get_drive_id(app, site_id, drive_name): logger.error(f"Drive '{drive_name}' not found. Available drives on this site: {available_names}") raise Exception(f"Drive {drive_name} not found. Check the log for available drive names.") +def get_all_drives(app, site_id): + url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives" + response = safe_graph_get(app, url) + return response.json().get('value', []) + # --- Punkt 2: Resume / Chunked Download logic --- def get_fresh_download_url(app, drive_id, item_id): """Fetches a fresh download URL for a specific item ID with retries and robust error handling.""" @@ -414,20 +423,32 @@ def main(config=None, stop_event=None): local_base = config.get('LOCAL_PATH', '').replace('\\', os.sep) folders = [f.strip() for f in folders_str.split(',') if f.strip()] or [""] - + logger.info("Initializing SharePoint Production Sync Tool...") app = create_msal_app(tenant_id, client_id, client_secret) site_id = get_site_id(app, site_url) - drive_id = get_drive_id(app, site_id, drive_name) - + + if not drive_name: + drives = get_all_drives(app, site_id) + logger.info(f"Downloading all {len(drives)} document libraries: {[d['name'] for d in drives]}") + use_subfolder = True + folders = [""] # Download fra rod af hvert bibliotek + else: + drives = [{'id': get_drive_id(app, site_id, drive_name), 'name': drive_name}] + use_subfolder = False + report = [] with ThreadPoolExecutor(max_workers=MAX_WORKERS, thread_name_prefix="DL") as executor: futures = {} - for folder in folders: + for drive in drives: if stop_event and stop_event.is_set(): break - logger.info(f"Scanning: {folder or 'Root'}") - process_item_list(app, drive_id, folder, os.path.join(local_base, folder), report, executor, futures, config, stop_event) + drive_local_base = os.path.join(local_base, drive['name']) if use_subfolder else local_base + for folder in folders: + if stop_event and stop_event.is_set(): + break + logger.info(f"[{drive['name']}] Scanning: {folder or 'Root'}") + process_item_list(app, drive['id'], folder, os.path.join(drive_local_base, folder), report, executor, futures, config, stop_event) logger.info(f"Scan complete. Processing {len(futures)} tasks...") for future in as_completed(futures): diff --git a/sharepoint_gui.py b/sharepoint_gui.py index 2afbfe1..d3ce45d 100644 --- a/sharepoint_gui.py +++ b/sharepoint_gui.py @@ -47,19 +47,15 @@ class SharepointApp(ctk.CTk): self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) self.entries = {} - fields = [ + + # Felter før DOCUMENT_LIBRARY + fields_before = [ ("TENANT_ID", "Tenant ID"), ("CLIENT_ID", "Client ID"), ("CLIENT_SECRET", "Client Secret"), ("SITE_URL", "Site URL"), - ("DOCUMENT_LIBRARY", "Library Navn"), - ("FOLDERS_TO_DOWNLOAD", "Mapper (komma-sep)"), - ("LOCAL_PATH", "Lokal Sti"), - ("ENABLE_HASH_VALIDATION", "Valider Hash (True/False)"), - ("HASH_THRESHOLD_MB", "Hash Grænse (MB)") ] - - for i, (key, label) in enumerate(fields): + for i, (key, label) in enumerate(fields_before): lbl = ctk.CTkLabel(self.sidebar_frame, text=label) lbl.grid(row=i*2+1, column=0, padx=20, pady=(5, 0), sticky="w") entry = ctk.CTkEntry(self.sidebar_frame, width=280) @@ -67,6 +63,36 @@ class SharepointApp(ctk.CTk): entry.grid(row=i*2+2, column=0, padx=20, pady=(0, 5)) self.entries[key] = entry + # DOCUMENT_LIBRARY med "Alle biblioteker"-afkrydsningsfelt + lbl_lib = ctk.CTkLabel(self.sidebar_frame, text="Library Navn") + lbl_lib.grid(row=9, column=0, padx=20, pady=(5, 0), sticky="w") + self.library_entry = ctk.CTkEntry(self.sidebar_frame, width=280) + self.library_entry.grid(row=10, column=0, padx=20, pady=(0, 2)) + self.entries["DOCUMENT_LIBRARY"] = self.library_entry + + self.all_libraries_var = ctk.BooleanVar(value=False) + self.all_libraries_cb = ctk.CTkCheckBox( + self.sidebar_frame, text="Download alle biblioteker", + variable=self.all_libraries_var, + command=self.toggle_library_entry + ) + self.all_libraries_cb.grid(row=11, column=0, padx=20, pady=(0, 5), sticky="w") + + # Felter efter DOCUMENT_LIBRARY + fields_after = [ + ("FOLDERS_TO_DOWNLOAD", "Mapper (komma-sep)"), + ("LOCAL_PATH", "Lokal Sti"), + ("ENABLE_HASH_VALIDATION", "Valider Hash (True/False)"), + ("HASH_THRESHOLD_MB", "Hash Grænse (MB)") + ] + for i, (key, label) in enumerate(fields_after): + base_row = 12 + i * 2 + lbl = ctk.CTkLabel(self.sidebar_frame, text=label) + lbl.grid(row=base_row, column=0, padx=20, pady=(5, 0), sticky="w") + entry = ctk.CTkEntry(self.sidebar_frame, width=280) + entry.grid(row=base_row+1, column=0, padx=20, pady=(0, 5)) + self.entries[key] = entry + self.browse_button = ctk.CTkButton(self.sidebar_frame, text="Vælg Mappe", command=self.browse_folder, height=32) self.browse_button.grid(row=20, column=0, padx=20, pady=10) @@ -104,6 +130,16 @@ class SharepointApp(ctk.CTk): handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S')) download_sharepoint.logger.addHandler(handler) + def toggle_library_entry(self): + if self.all_libraries_var.get(): + self.library_entry.configure(state="normal") + self.library_entry.delete(0, "end") + self.library_entry.configure(state="disabled") + self.entries["FOLDERS_TO_DOWNLOAD"].configure(state="disabled") + else: + self.library_entry.configure(state="normal") + self.entries["FOLDERS_TO_DOWNLOAD"].configure(state="normal") + def browse_folder(self): path = filedialog.askdirectory() if path: @@ -116,6 +152,10 @@ class SharepointApp(ctk.CTk): for key, entry in self.entries.items(): val = config.get(key, "") entry.insert(0, val) + if not config.get("DOCUMENT_LIBRARY", ""): + self.all_libraries_cb.select() + self.library_entry.configure(state="disabled") + self.entries["FOLDERS_TO_DOWNLOAD"].configure(state="disabled") def save_settings(self): config_lines = [f'{k} = "{v.get()}"' for k, v in self.entries.items()]