import os import time import threading import hashlib import json import sys import requests import msal import wx import wx.lib.newevent import webbrowser import re import ctypes import base64 import logging from ctypes import wintypes # --- LOGGING & KONSTANTER --- def setup_logging(enabled=True): level = logging.INFO if enabled else logging.CRITICAL # Fjern eksisterende handlers hvis vi kalder den igen for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=level, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger("SP_Browser") # Initial setup (INFO) - vil blive opdateret efter settings er indlæst setup_logging(True) CHUNK_SIZE = 1 * 1024 * 1024 # 1MB ENABLE_HASH_VALIDATION = True HASH_THRESHOLD_MB = 250 try: import quickxorhash as qxh_lib except ImportError: qxh_lib = None # --- STIHÅNDTERING (Til EXE-brug) --- if getattr(sys, 'frozen', False): # RESOURCE_DIR er mapper indeni EXE-filen (ikoner, billeder) RESOURCE_DIR = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) # CONFIG_DIR er mappen hvor selve .exe filen ligger (settings.json) CONFIG_DIR = os.path.dirname(sys.executable) else: RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_DIR = RESOURCE_DIR SETTINGS_FILE = os.path.join(CONFIG_DIR, 'settings.json') def load_settings(): default_settings = { "client_id": "DIN_CLIENT_ID_HER", "tenant_id": "DIN_TENANT_ID_HER", "temp_dir": "C:\\Temp_SP", "language": "da", # da eller en "favorites": [], # Liste over {id, name, type, drive_id, site_id, path} "fav_visible": True, "logging_enabled": True, "license_key": "" } if not os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE, 'w', encoding='utf-8') as f: json.dump(default_settings, f, indent=4) return default_settings with open(SETTINGS_FILE, 'r', encoding='utf-8') as f: try: return json.load(f) except Exception: return default_settings def save_settings(new_settings): with open(SETTINGS_FILE, 'w', encoding='utf-8') as f: json.dump(new_settings, f, indent=4) # --- GRAPH API REQUEST HELPER --- _RETRY_STATUSES = {429, 503} _MAX_RETRIES = 3 def _graph_request(method, url, **kwargs): """Thin wrapper around requests.request with retry for 429/503. Retries up to _MAX_RETRIES times when Graph API signals rate limiting (429) or transient unavailability (503), honouring the Retry-After header when present. A default timeout of 30 s is injected if the caller does not supply one. File-upload calls that pass an open stream as data= should use requests.put() directly, since a stream cannot be re-read. """ kwargs.setdefault("timeout", 30) for attempt in range(_MAX_RETRIES): res = requests.request(method, url, **kwargs) if res.status_code not in _RETRY_STATUSES: return res if attempt < _MAX_RETRIES - 1: # Don't sleep after the final attempt retry_after = int(res.headers.get("Retry-After", 2 ** attempt)) time.sleep(min(retry_after, 60)) return res # Return last response after exhausting retries settings = load_settings() CLIENT_ID = settings.get("client_id") TENANT_ID = settings.get("tenant_id") AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPES = ["Files.ReadWrite.All", "Sites.Read.All", "User.Read"] TEMP_DIR = settings.get("temp_dir", "C:\\Temp_SP") CURRENT_LANG = settings.get("language", "da") # Opdater logging baseret på gemte indstillinger setup_logging(settings.get("logging_enabled", True)) # --- TRANSLATIONS --- STRINGS = { "da": { "title": "SharePoint Explorer", "btn_back": "Tilbage", "btn_home": "Hjem", "btn_save_changes": "Gem ændringer i SharePoint", "btn_login": "Log ind", "btn_logged_in": "Logget ind", "btn_refresh": "Opdater", "btn_upload_file": "Upload Fil", "btn_upload_folder": "Upload Mappe", "btn_new_folder": "Ny Mappe", "col_name": "Navn", "col_type": "Type", "col_size": "Størrelse", "col_modified": "Sidst ændret", "type_folder": "Mappe", "type_file": "Fil", "type_drive": "Bibliotek", "type_site": "Site", "status_ready": "Klar", "status_logging_in": "Logger ind...", "status_fetching_sites": "Henter sites...", "status_loading": "Indlæser...", "status_loading_content": "Indlæser indhold...", "status_fetching_drives": "Henter biblioteker...", "msg_confirm_delete_single": "Er du sikker på, at du vil slette '{name}' permanent fra SharePoint?", "msg_confirm_delete_multi": "Er du sikker på, at du vil slette {num} markerede emner permanent fra SharePoint?\n\n{names}", "msg_delete_title": "Bekræft sletning", "msg_error": "Fejl", "msg_success": "Succes!", "msg_edit_file": "Rediger fil", "msg_delete": "Slet", "msg_rename": "Omdøb", "msg_rename_prompt": "Indtast det nye navn for '{name}':", "msg_rename_title": "Omdøb emne", "msg_open_browser": "Åbn i browser", "msg_download": "Download", "msg_downloading_to": "Downloader '{name}' til '{path}'...", "msg_download_done": "'{name}' downloadet færdig.", "msg_upload_here": "Upload fil her", "msg_upload_folder_here": "Upload mappe her", "msg_new_folder_here": "Opret ny mappe her", "msg_uploading": "Uploader '{name}'...", "msg_creating_folder": "Opretter mappen '{name}'...", "msg_folder_done": "Mappe '{name}' færdig.", "msg_new_folder_prompt": "Indtast navnet på den nye mappe:", "msg_new_folder_title": "Ny Mappe", "msg_drop_info": "Du kan kun uploade filer, når du er inde i et bibliotek eller en mappe.", "msg_drop_title": "Vælg lokation", "msg_select_file": "Vælg fil til upload", "msg_select_folder": "Vælg mappe til upload", "msg_edit_warning": "Du er i gang med at redigere en fil. Luk din editor og gem ændringerne før du lukker programmet.", "msg_login_failed": "Login fejlede.", "msg_upload_success": "'{name}' uploadet succesfuldt.", "msg_upload_failed": "Upload af '{name}' fejlede med kode {code}", "msg_delete_failed": "Kunne ikke slette '{name}'. Stopper...", "msg_deleted_status": "Slettet {count} af {total} emner.", "msg_fetching_file": "Henter '{name}'...", "msg_opening_file": "Åbner '{name}'...", "msg_waiting_for_file": "Venter på '{name}'...", "msg_editing_file": "Redigerer '{name}' - Luk for at gemme.", "msg_file_unchanged": "Ingen ændringer fundet. Springer upload over.", "msg_updating_changes": "Uploader ændringer...", "msg_checking_in": "Tjekker '{name}' ind...", "msg_update_success": "Succes! '{name}' er opdateret.", "msg_update_failed_code": "Upload fejlede: {code}", "msg_unknown_error": "Ukendt fejl", "type_unknown": "Ukendt", "btn_settings": "Indstillinger", "settings_title": "Indstillinger", "settings_auth_group": "Authentication / API", "settings_client_id": "App (Client) ID:", "settings_tenant_id": "Tenant ID:", "settings_path_group": "Systemstier", "settings_temp_dir": "Midlertidig mappe:", "settings_app_path": "Applikationssti:", "settings_active_temp_path": "Aktuel Temp-sti:", "settings_lang_group": "Sprog / UI", "settings_language": "Programsprog:", "settings_save": "Gem indstillinger", "settings_cancel": "Annuller", "msg_settings_saved": "Indstillingerne er gemt.", "msg_restart_required": "Visse ændringer (f.eks. ID'er) træder først i kraft efter genstart.", "status_login_needed": "Session udløbet. Log ind igen.", "btn_add_fav": "Tilføj til favoritter", "btn_remove_fav": "Fjern fra favoritter", "label_favorites": "Favoritter", "msg_fav_exists": "'{name}' er allerede i favoritter.", "settings_license_group": "Licens / Aktivering", "settings_license_key": "Licensnøgle:", "settings_license_status": "Status: Ikke aktiveret", "settings_logging_group": "System / Diverse", "settings_logging": "Aktiver log-output (anbefales til fejlfinding)", "status_loading_items": "Henter... ({count} emner)", "settings_about_group": "Om programmet", "settings_credits": "© 2026 SharePoint Explorer\n\nSkabt af:\nMartin Tranberg\nBlueprint\n\nBernhard Bangs Allé 23, 2.\n2000 Frederiksberg\n\nTel: 70258689" }, "en": { "title": "SharePoint Explorer", "btn_back": "Back", "btn_home": "Home", "btn_save_changes": "Save changes to SharePoint", "btn_login": "Login", "btn_logged_in": "Logged in", "btn_refresh": "Refresh", "btn_upload_file": "Upload File", "btn_upload_folder": "Upload Folder", "btn_new_folder": "New Folder", "col_name": "Name", "col_type": "Type", "col_size": "Size", "col_modified": "Last Modified", "type_folder": "Folder", "type_file": "File", "type_drive": "Library", "type_site": "Site", "status_ready": "Ready", "status_logging_in": "Logging in...", "status_fetching_sites": "Fetching sites...", "status_loading": "Loading...", "status_loading_content": "Loading content...", "status_fetching_drives": "Fetching libraries...", "msg_confirm_delete_single": "Are you sure you want to permanently delete '{name}' from SharePoint?", "msg_confirm_delete_multi": "Are you sure you want to permanently delete {num} selected items from SharePoint?\n\n{names}", "msg_delete_title": "Confirm Delete", "msg_error": "Error", "msg_success": "Success!", "msg_edit_file": "Edit file", "msg_delete": "Delete", "msg_rename": "Rename", "msg_rename_prompt": "Enter new name for '{name}':", "msg_rename_title": "Rename item", "msg_open_browser": "Open in browser", "msg_download": "Download", "msg_downloading_to": "Downloading '{name}' to '{path}'...", "msg_download_done": "'{name}' download finished.", "msg_upload_here": "Upload file here", "msg_upload_folder_here": "Upload folder here", "msg_new_folder_here": "Create new folder here", "msg_uploading": "Uploading '{name}'...", "msg_creating_folder": "Creating folder '{name}'...", "msg_folder_done": "Folder '{name}' finished.", "msg_new_folder_prompt": "Enter the name of the new folder:", "msg_new_folder_title": "New Folder", "msg_drop_info": "You can only upload files when you are inside a library or a folder.", "msg_drop_title": "Select Location", "msg_select_file": "Select file to upload", "msg_select_folder": "Select folder to upload", "msg_edit_warning": "An editing task is active. Please close your editor and save changes before closing the app.", "msg_login_failed": "Login failed.", "msg_upload_success": "'{name}' uploaded successfully.", "msg_upload_failed": "Upload of '{name}' failed with status {code}", "msg_delete_failed": "Could not delete '{name}'. Stopping...", "msg_deleted_status": "Deleted {count} of {total} items.", "msg_fetching_file": "Fetching '{name}'...", "msg_opening_file": "Opening '{name}'...", "msg_waiting_for_file": "Waiting for '{name}'...", "msg_editing_file": "Editing '{name}' - Close window to save.", "msg_file_unchanged": "No changes found. Skipping upload.", "msg_updating_changes": "Uploading changes...", "msg_checking_in": "Checking in '{name}'...", "msg_update_success": "Success! '{name}' has been updated.", "msg_update_failed_code": "Upload failed: {code}", "msg_unknown_error": "Unknown error", "type_unknown": "Unknown", "btn_settings": "Settings", "settings_title": "Settings", "settings_auth_group": "Authentication / API", "settings_client_id": "App (Client) ID:", "settings_tenant_id": "Tenant ID:", "settings_path_group": "System Paths", "settings_temp_dir": "Temporary folder:", "settings_app_path": "Application path:", "settings_active_temp_path": "Active Temp path:", "settings_lang_group": "Language / UI", "settings_language": "App Language:", "settings_save": "Save Settings", "settings_cancel": "Cancel", "msg_settings_saved": "Settings saved.", "msg_restart_required": "Some changes (e.g., IDs) only take effect after restart.", "status_login_needed": "Session expired. Please login again.", "btn_add_fav": "Add to favorites", "btn_remove_fav": "Remove from favorites", "label_favorites": "Favorites", "msg_fav_exists": "'{name}' is already in favorites.", "settings_license_group": "License / Activation", "settings_license_key": "License Key:", "settings_license_status": "Status: Not activated", "settings_logging_group": "System / Miscellaneous", "settings_logging": "Enable log output (recommended for troubleshooting)", "status_loading_items": "Loading... ({count} items)", "settings_about_group": "About", "settings_credits": "© 2026 SharePoint Explorer\n\nCreated by:\nMartin Tranberg\nBlueprint\n\nBernhard Bangs Allé 23, 2.\nDK-2000 Frederiksberg\n\nPhone: +45 70258689" } } if not os.path.exists(TEMP_DIR): os.makedirs(TEMP_DIR) def natural_sort_key(s): return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', str(s))] # --- NATIVE WINDOWS ICON HANDLING --- if os.name == 'nt': class SHFILEINFO(ctypes.Structure): _fields_ = [ ("hIcon", wintypes.HANDLE), ("iIcon", ctypes.c_int), ("dwAttributes", wintypes.DWORD), ("szDisplayName", wintypes.WCHAR * 260), ("szTypeName", wintypes.WCHAR * 80), ] SHGFI_ICON = 0x000000100 SHGFI_SMALLICON = 0x000000001 SHGFI_USEFILEATTRIBUTES = 0x000000010 class UploadDropTarget(wx.FileDropTarget): def __init__(self, window, app): wx.FileDropTarget.__init__(self) self.window = window self.app = app def OnDropFiles(self, x, y, filenames): if not self.app.current_drive_id: wx.MessageBox(self.app.get_txt("msg_drop_info"), self.app.get_txt("msg_drop_title"), wx.OK | wx.ICON_INFORMATION) return False for path in filenames: if os.path.isfile(path): threading.Thread(target=self.app._upload_file_bg, args=(path, self.app.current_drive_id, self.app.current_folder_id), daemon=True).start() elif os.path.isdir(path): threading.Thread(target=self.app._upload_folder_bg, args=(path, self.app.current_drive_id, self.app.current_folder_id), daemon=True).start() return True CHUNK_SIZE = 1 * 1024 * 1024 # 1MB chunks ENABLE_HASH_VALIDATION = True HASH_THRESHOLD_MB = 250 # Grænse for hvornår vi gider tjekke hash (pga. hastighed i Python) # --- HJÆLPEFUNKTIONER --- def get_long_path(path): if os.name == 'nt' and not path.startswith('\\\\?\\'): return '\\\\?\\' + os.path.abspath(path) return path def quickxorhash(file_path): """Compute Microsoft QuickXorHash for a file. Returns base64-encoded string.""" lp = get_long_path(file_path) if not os.path.exists(lp): return None if qxh_lib: hasher = qxh_lib.quickxorhash() with open(lp, 'rb') as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break hasher.update(chunk) return base64.b64encode(hasher.digest()).decode('ascii') # Fallback til manuel Python implementering h = 0 length = 0 mask = (1 << 160) - 1 with open(lp, 'rb') as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break for b in chunk: shift = (length * 11) % 160 shifted = b << shift wrapped = (shifted & mask) | (shifted >> 160) h ^= wrapped length += 1 h ^= (length << (160 - 64)) result = h.to_bytes(20, byteorder='little') return base64.b64encode(result).decode('ascii') def verify_integrity(local_path, remote_hash): """Verifies file integrity based on global settings.""" if not remote_hash or not ENABLE_HASH_VALIDATION: return True lp = get_long_path(local_path) if not os.path.exists(lp): return False file_size = os.path.getsize(lp) threshold_bytes = HASH_THRESHOLD_MB * 1024 * 1024 if file_size > threshold_bytes: logger.info(f"Skipping hash check (size > {HASH_THRESHOLD_MB}MB): {os.path.basename(local_path)}") return True local_hash = quickxorhash(local_path) if local_hash != remote_hash: logger.warning(f"Hash mismatch for {local_path}: local={local_hash}, remote={remote_hash}") return False return True def format_size(bytes_num): if bytes_num is None: return "" if bytes_num < 1024: return f"{bytes_num} B" elif bytes_num < 1024**2: return f"{bytes_num/1024:.1f} KB" elif bytes_num < 1024**3: return f"{bytes_num/(1024**2):.1f} MB" else: return f"{bytes_num/(1024**3):.1f} GB" def is_configured(cfg): placeholders = ["DIN_CLIENT_ID_HER", "DIN_TENANT_ID_HER", ""] return cfg.get("client_id") not in placeholders and cfg.get("tenant_id") not in placeholders class SettingsDialog(wx.Dialog): def __init__(self, parent, current_settings): lang = current_settings.get("language", "da") title = STRINGS[lang].get("settings_title", "Settings") super().__init__(parent, title=title, size=(580, 550)) self.settings = current_settings.copy() self.lang = lang self.InitUI() def get_txt(self, key): return STRINGS[self.lang].get(key, key) def InitUI(self): vbox = wx.BoxSizer(wx.VERTICAL) # --- TABBED INTERFACE (wx.Notebook) --- self.nb = wx.Notebook(self) # 1. ACCOUNT TAB account_panel = wx.Panel(self.nb) account_vbox = wx.BoxSizer(wx.VERTICAL) grid = wx.FlexGridSizer(2, 2, 10, 10) grid.AddGrowableCol(1, 1) grid.Add(wx.StaticText(account_panel, label=self.get_txt("settings_client_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.client_id_ctrl = wx.TextCtrl(account_panel, value=self.settings.get("client_id", ""), size=(-1, 25)) grid.Add(self.client_id_ctrl, 1, wx.EXPAND) grid.Add(wx.StaticText(account_panel, label=self.get_txt("settings_tenant_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.tenant_id_ctrl = wx.TextCtrl(account_panel, value=self.settings.get("tenant_id", ""), size=(-1, 25)) grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND) account_vbox.Add(grid, 1, wx.EXPAND | wx.ALL, 15) account_panel.SetSizer(account_vbox) self.nb.AddPage(account_panel, self.get_txt("settings_auth_group").split("/")[0].strip()) # 2. PATHS TAB paths_panel = wx.Panel(self.nb) paths_vbox = wx.BoxSizer(wx.VERTICAL) paths_inner = wx.BoxSizer(wx.VERTICAL) paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_temp_dir")), 0, wx.BOTTOM, 5) self.temp_dir_picker = wx.DirPickerCtrl(paths_panel, path=self.settings.get("temp_dir", "C:\\Temp_SP"), style=wx.DIRP_DIR_MUST_EXIST) paths_inner.Add(self.temp_dir_picker, 0, wx.EXPAND | wx.BOTTOM, 15) paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_app_path")), 0, wx.BOTTOM, 5) app_path_box = wx.TextCtrl(paths_panel, value=CONFIG_DIR, style=wx.TE_READONLY | wx.BORDER_NONE) app_path_box.SetBackgroundColour(paths_panel.GetBackgroundColour()) paths_inner.Add(app_path_box, 0, wx.EXPAND | wx.BOTTOM, 15) paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_active_temp_path")), 0, wx.BOTTOM, 5) temp_path_box = wx.TextCtrl(paths_panel, value=TEMP_DIR, style=wx.TE_READONLY | wx.BORDER_NONE) temp_path_box.SetBackgroundColour(paths_panel.GetBackgroundColour()) paths_inner.Add(temp_path_box, 0, wx.EXPAND) paths_vbox.Add(paths_inner, 1, wx.EXPAND | wx.ALL, 15) paths_panel.SetSizer(paths_vbox) self.nb.AddPage(paths_panel, self.get_txt("settings_path_group")) # 3. LICENSE TAB lic_panel = wx.Panel(self.nb) lic_vbox = wx.BoxSizer(wx.VERTICAL) lic_inner = wx.BoxSizer(wx.VERTICAL) lic_inner.Add(wx.StaticText(lic_panel, label=self.get_txt("settings_license_key")), 0, wx.BOTTOM, 5) self.license_ctrl = wx.TextCtrl(lic_panel, value=self.settings.get("license_key", "")) lic_inner.Add(self.license_ctrl, 0, wx.EXPAND | wx.BOTTOM, 5) status_txt = wx.StaticText(lic_panel, label=self.get_txt("settings_license_status")) status_txt.SetForegroundColour(wx.RED) lic_inner.Add(status_txt, 0, wx.TOP, 5) lic_vbox.Add(lic_inner, 1, wx.EXPAND | wx.ALL, 15) lic_panel.SetSizer(lic_vbox) self.nb.AddPage(lic_panel, self.get_txt("settings_license_group").split("/")[0].strip()) # 4. SYSTEM TAB sys_panel = wx.Panel(self.nb) sys_vbox = wx.BoxSizer(wx.VERTICAL) sys_inner = wx.BoxSizer(wx.VERTICAL) lang_hbox = wx.BoxSizer(wx.HORIZONTAL) lang_hbox.Add(wx.StaticText(sys_panel, label=self.get_txt("settings_language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) self.lang_choice = wx.Choice(sys_panel, choices=["Dansk", "English"]) self.lang_choice.SetSelection(0 if self.settings.get("language") == "da" else 1) lang_hbox.Add(self.lang_choice, 1, wx.EXPAND) sys_inner.Add(lang_hbox, 0, wx.EXPAND | wx.BOTTOM, 15) self.logging_cb = wx.CheckBox(sys_panel, label=self.get_txt("settings_logging")) self.logging_cb.SetValue(self.settings.get("logging_enabled", True)) sys_inner.Add(self.logging_cb, 0, wx.ALL, 5) sys_vbox.Add(sys_inner, 1, wx.EXPAND | wx.ALL, 15) sys_panel.SetSizer(sys_vbox) self.nb.AddPage(sys_panel, self.get_txt("settings_logging_group").split("/")[0].strip()) # 5. ABOUT TAB about_panel = wx.Panel(self.nb) about_vbox = wx.BoxSizer(wx.VERTICAL) about_info = wx.TextCtrl(about_panel, value=self.get_txt("settings_credits"), style=wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE) about_info.SetBackgroundColour(about_panel.GetBackgroundColour()) about_vbox.Add(about_info, 1, wx.EXPAND | wx.ALL, 15) about_panel.SetSizer(about_vbox) self.nb.AddPage(about_panel, self.get_txt("settings_about_group")) vbox.Add(self.nb, 1, wx.EXPAND | wx.ALL, 10) # --- BUTTONS --- btn_hbox = wx.BoxSizer(wx.HORIZONTAL) save_btn = wx.Button(self, label=self.get_txt("settings_save"), size=(150, 35)) save_btn.SetBackgroundColour(wx.Colour(0, 120, 215)) # SharePoint Blue save_btn.SetForegroundColour(wx.WHITE) save_btn.Bind(wx.EVT_BUTTON, self.on_save) cancel_btn = wx.Button(self, label=self.get_txt("settings_cancel"), size=(100, 35)) cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel) btn_hbox.Add(save_btn, 0, wx.RIGHT, 10) btn_hbox.Add(cancel_btn, 0) vbox.Add(btn_hbox, 0, wx.ALIGN_RIGHT | wx.ALL, 15) self.SetSizer(vbox) self.Layout() self.Centre() def on_save(self, event): self.settings["client_id"] = self.client_id_ctrl.GetValue().strip() self.settings["tenant_id"] = self.tenant_id_ctrl.GetValue().strip() self.settings["temp_dir"] = self.temp_dir_picker.GetPath() self.settings["language"] = "da" if self.lang_choice.GetSelection() == 0 else "en" self.settings["license_key"] = self.license_ctrl.GetValue().strip() self.settings["logging_enabled"] = self.logging_cb.GetValue() # Anvend logning med det samme setup_logging(self.settings["logging_enabled"]) if not self.settings["client_id"] or not self.settings["tenant_id"]: wx.MessageBox("Client ID og Tenant ID skal udfyldes.", "Fejl", wx.OK | wx.ICON_ERROR) return self.EndModal(wx.ID_OK) def on_cancel(self, event): self.EndModal(wx.ID_CANCEL) class StartGuideDialog(wx.Dialog): def __init__(self, parent, current_settings): lang = current_settings.get("language", "da") title = "Velkommen til SharePoint Explorer" if lang == "da" else "Welcome to SharePoint Explorer" super().__init__(parent, title=title, size=(600, 650)) self.settings = current_settings.copy() self.lang = lang self.InitUI() self.Center() def get_txt(self, key): return STRINGS[self.lang].get(key, key) def InitUI(self): panel = wx.Panel(self) vbox = wx.BoxSizer(wx.VERTICAL) # Titel title_lbl = wx.StaticText(panel, label="Opsætning af SharePoint Explorer" if self.lang == "da" else "SharePoint Explorer Setup") title_font = title_lbl.GetFont() title_font.MakeBold() title_font.SetPointSize(14) title_lbl.SetFont(title_font) vbox.Add(title_lbl, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, 15) # Guide tekst guide_text_da = ( "For at programmet kan forbinde til din Microsoft 365, skal du oprette en App Registrering i Azure Portal.\n\n" "Følg disse trin:\n" "1. Gå til portal.azure.com og log ind med din administrator konto.\n" "2. Søg efter 'App registrations' og klik på 'New registration'.\n" "3. Giv den et navn (f.eks. 'SharePoint Explorer') og vælg 'Accounts in any organizational directory' (eller 'Accounts in this organizational directory only' hvis det kun er internt).\n" "4. Vælg 'Public client/native (mobile & desktop)' under Redirect URI og skriv:\n http://localhost\n" "5. Klik 'Register'.\n\n" "Giv tilladelser (API permissions):\n" "1. Klik på 'API permissions' i menuen til venstre.\n" "2. Klik 'Add a permission' -> 'Microsoft Graph' -> 'Delegated permissions'.\n" "3. Tilføj følgende: Files.ReadWrite.All, Sites.Read.All, og User.Read.\n" "4. Husk at klikke 'Grant admin consent'.\n" "5. Under 'Authentication' i menuen til venstre, sørg for at 'Allow public client flows' er aktiveret (slået til) i bunden af siden.\n\n" "Kopier nu disse to værdier fra 'Overview' siden og sæt ind herunder:" ) guide_ctrl = wx.TextCtrl(panel, value=guide_text_da, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE) guide_ctrl.SetBackgroundColour(panel.GetBackgroundColour()) font = guide_ctrl.GetFont() font.SetPointSize(10) guide_ctrl.SetFont(font) vbox.Add(guide_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 20) # Input felter grid = wx.FlexGridSizer(2, 2, 10, 10) grid.AddGrowableCol(1, 1) lbl_client = wx.StaticText(panel, label="App (Client) ID:") self.client_id_ctrl = wx.TextCtrl(panel, value="") grid.Add(lbl_client, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) grid.Add(self.client_id_ctrl, 1, wx.EXPAND) lbl_tenant = wx.StaticText(panel, label="Directory (Tenant) ID:") self.tenant_id_ctrl = wx.TextCtrl(panel, value="") grid.Add(lbl_tenant, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND) vbox.Add(grid, 0, wx.EXPAND | wx.ALL, 20) # Gem/Annuller btn_box = wx.BoxSizer(wx.HORIZONTAL) save_btn = wx.Button(panel, label=self.get_txt("settings_save"), size=(150, 40)) save_btn.SetBackgroundColour(wx.Colour(0, 120, 215)) save_btn.SetForegroundColour(wx.WHITE) save_btn.Bind(wx.EVT_BUTTON, self.on_save) cancel_btn = wx.Button(panel, label=self.get_txt("settings_cancel"), size=(100, 40)) cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel) btn_box.Add(save_btn, 0, wx.RIGHT, 10) btn_box.Add(cancel_btn, 0) vbox.Add(btn_box, 0, wx.ALIGN_RIGHT | wx.ALL, 15) panel.SetSizer(vbox) def on_save(self, event): cid = self.client_id_ctrl.GetValue().strip() tid = self.tenant_id_ctrl.GetValue().strip() if not cid or not tid: wx.MessageBox("Begge felter skal udfyldes for at fortsætte." if self.lang == "da" else "Both fields are required.", "Fejl", wx.OK | wx.ICON_ERROR) return self.settings["client_id"] = cid self.settings["tenant_id"] = tid save_settings(self.settings) wx.MessageBox("Nye indstillinger gemt. Programmet genstarter for at anvende de nye ID'er." if self.lang == "da" else "Settings saved. App will restart.", "Succes", wx.OK | wx.ICON_INFORMATION) self.EndModal(wx.ID_OK) def on_cancel(self, event): self.EndModal(wx.ID_CANCEL) class SharePointApp(wx.Frame): def __init__(self): self.lang = CURRENT_LANG super().__init__(None, title=self.get_txt("title"), size=(1000, 750)) # State self.access_token = None self.headers = {} self.history = [] # Stack af (mode, id, path_segment) self.current_path = ["SharePoint"] self.current_site_id = None self.current_drive_id = None self.current_folder_id = "root" self.current_items = [] # Gemmer graf-objekterne for rækkerne self.tree_item_data = {} # Mappenoder -> {type, id, name, drive_id, path} self.current_path_data = [] # Gemmer data-objekterne for den nuværende sti (brødkrummer) self._nav_gen = 0 # Incremented on each navigation to discard stale fetch results self.tree_root = None self.is_navigating_back = False self.active_edits = {} # item_id -> { "name": name, "event": Event, "waiting": bool } self._edits_lock = threading.Lock() self.favorites = settings.get("favorites", []) self.fav_visible = settings.get("fav_visible", True) self.sort_col = 0 # Default (Navn) self.sort_asc = True self.compact_mode = False self.ext_icons = {} # Mapping fra .ext -> index i image_list self.current_web_url = None # URL til nuværende lokation i browser # System Ikoner self.image_list = wx.ImageList(16, 16) def add_icon(art_id, client=wx.ART_CMN_DIALOG): bmp = wx.ArtProvider.GetBitmap(art_id, client, (16, 16)) if not bmp.IsOk(): bmp = wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, client, (16, 16)) # Fallback return self.image_list.Add(bmp) self.idx_site = add_icon(wx.ART_GO_HOME) self.idx_drive = add_icon(wx.ART_HARDDISK) self.idx_folder = add_icon(wx.ART_FOLDER) self.idx_file = add_icon(wx.ART_NORMAL_FILE) self.idx_star = add_icon(wx.ART_ADD_BOOKMARK) self.idx_up = add_icon(wx.ART_GO_UP, wx.ART_TOOLBAR) self.idx_down = add_icon(wx.ART_GO_DOWN, wx.ART_TOOLBAR) # Threading/Sync til filredigering # MSAL Cache self.msal_app = None if is_configured(settings): try: self.msal_app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY) except Exception as e: logger.error(f"MSAL Init Error: {e}") self.InitUI() self.Centre() self.Show() self.Bind(wx.EVT_CLOSE, self.on_close_window) # SÆT VINDUESIKON (øverste venstre hjørne) icon_path = os.path.join(RESOURCE_DIR, "icon.ico") if os.path.exists(icon_path): self.SetIcon(wx.Icon(icon_path, wx.BITMAP_TYPE_ICO)) # Start indlæsning (Check for konfiguration) if not is_configured(settings): wx.CallAfter(self.show_start_guide) def show_start_guide(self): dlg = StartGuideDialog(self, settings) if dlg.ShowModal() == wx.ID_OK: import sys os.execl(sys.executable, sys.executable, *sys.argv) else: self.Close() dlg.Destroy() def get_txt(self, key, **kwargs): text = STRINGS[self.lang].get(key, key) if kwargs: try: return text.format(**kwargs) except Exception: pass return text def InitUI(self): panel = wx.Panel(self) main_sizer = wx.BoxSizer(wx.VERTICAL) self.SetSizer(main_sizer) main_sizer.Add(panel, 1, wx.EXPAND) vbox = wx.BoxSizer(wx.VERTICAL) # 0. INFO BAR (Beskeder der ikke blokerer) self.info_bar = wx.InfoBar(panel) vbox.Add(self.info_bar, 0, wx.EXPAND) # Timer til at skjule info bar self.info_timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, lambda e: self.info_bar.Dismiss(), self.info_timer) # 1. TOP NAVIGATION BAR nav_panel = wx.Panel(panel) nav_hbox = wx.BoxSizer(wx.HORIZONTAL) self.back_btn = wx.Button(nav_panel, label=self.get_txt("btn_back"), size=(110, 30)) self.back_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_BACK, wx.ART_BUTTON, (16, 16))) self.back_btn.SetBitmapMargins((12, 0)) self.back_btn.Disable() self.back_btn.Bind(wx.EVT_BUTTON, self.go_back) nav_hbox.Add(self.back_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10) self.home_btn = wx.Button(nav_panel, label=self.get_txt("btn_home"), size=(110, 30)) self.home_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_BUTTON, (16, 16))) self.home_btn.SetBitmapMargins((12, 0)) self.home_btn.Disable() self.home_btn.Bind(wx.EVT_BUTTON, self.load_sites) nav_hbox.Add(self.home_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5) self.refresh_btn = wx.Button(nav_panel, label=self.get_txt("btn_refresh"), size=(110, 30)) self.refresh_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_BUTTON, (16, 16))) self.refresh_btn.SetBitmapMargins((12, 0)) self.refresh_btn.Disable() self.refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh) nav_hbox.Add(self.refresh_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5) # NY KNAP: Gem ændringer (Vises kun ved redigering) self.done_btn = wx.Button(nav_panel, label=self.get_txt("btn_save_changes"), size=(250, 30)) self.done_btn.SetBackgroundColour(wx.Colour(255, 69, 0)) # OrangeRed self.done_btn.SetForegroundColour(wx.WHITE) self.done_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_SAVE, wx.ART_BUTTON, (16, 16))) self.done_btn.SetBitmapMargins((12, 0)) self.done_btn.Hide() self.done_btn.Bind(wx.EVT_BUTTON, self.on_done_editing_clicked) nav_hbox.Add(self.done_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10) # NYE KNAPPER: Upload og Ny Mappe (Vises kun når man er inde i et drev/mappe) self.upload_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_file"), size=(130, 30)) self.upload_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_BUTTON, (16, 16))) self.upload_btn.SetBitmapMargins((12, 0)) self.upload_btn.Hide() self.upload_btn.Bind(wx.EVT_BUTTON, self.on_upload_clicked) nav_hbox.Add(self.upload_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5) self.upload_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_folder"), size=(130, 30)) self.upload_folder_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FOLDER_OPEN, wx.ART_BUTTON, (16, 16))) self.upload_folder_btn.SetBitmapMargins((12, 0)) self.upload_folder_btn.Hide() self.upload_folder_btn.Bind(wx.EVT_BUTTON, self.on_upload_folder_clicked) nav_hbox.Add(self.upload_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5) self.new_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_new_folder"), size=(120, 30)) self.new_folder_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_NEW_DIR, wx.ART_BUTTON, (16, 16))) self.new_folder_btn.SetBitmapMargins((12, 0)) self.new_folder_btn.Hide() self.new_folder_btn.Bind(wx.EVT_BUTTON, self.on_new_folder_clicked) nav_hbox.Add(self.new_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5) nav_hbox.AddStretchSpacer(1) self.login_btn = wx.Button(nav_panel, label=self.get_txt("btn_login"), size=(130, 30)) self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn self.login_btn.SetForegroundColour(wx.WHITE) self.login_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_TOOLBAR, (16, 16))) self.login_btn.SetBitmapMargins((12, 0)) self.login_btn.Bind(wx.EVT_BUTTON, self.login) nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10) # INDSTILLINGER KNAP self.settings_btn = wx.Button(nav_panel, label=self.get_txt("btn_settings"), size=(130, 30)) self.settings_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REPORT_VIEW, wx.ART_TOOLBAR, (16, 16))) self.settings_btn.SetBitmapMargins((12, 0)) self.settings_btn.Bind(wx.EVT_BUTTON, self.on_settings_clicked) nav_hbox.Add(self.settings_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10) nav_panel.SetSizer(nav_hbox) vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5) self.nav_hbox = nav_hbox # Gem til resize self.nav_panel = nav_panel # 2. PATH BREADCRUMBS (Adresselinje-stil) self.path_panel = wx.Panel(panel, style=wx.BORDER_SIMPLE) self.path_panel.SetBackgroundColour(wx.WHITE) self.path_panel.SetMinSize((-1, 38)) # Sikrer synbarhed self.path_sizer = wx.BoxSizer(wx.HORIZONTAL) self.path_panel.SetSizer(self.path_sizer) vbox.Add(self.path_panel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # 3. SPLITTER FOR TREE AND LIST self.splitter = wx.SplitterWindow(panel, style=wx.SP_LIVE_UPDATE | wx.SP_3DSASH) # Left side: Tree and Favorites Container self.left_container = wx.Panel(self.splitter) self.left_vbox = wx.BoxSizer(wx.VERTICAL) self.left_container.SetSizer(self.left_vbox) # LEFT SIDE - TOP: Tree self.tree_ctrl = wx.TreeCtrl(self.left_container, style=wx.TR_DEFAULT_STYLE | wx.TR_HIDE_ROOT | wx.BORDER_SUNKEN) self.tree_ctrl.AssignImageList(self.image_list) self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_EXPANDING, self.on_tree_expanding) self.tree_ctrl.Bind(wx.EVT_TREE_SEL_CHANGED, self.on_tree_selected) self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_MENU, self.on_tree_right_click) self.left_vbox.Add(self.tree_ctrl, 1, wx.EXPAND) # LEFT SIDE - BOTTOM: Favorites self.fav_section = wx.Panel(self.left_container) self.fav_vbox = wx.BoxSizer(wx.VERTICAL) self.fav_section.SetSizer(self.fav_vbox) # Fav Header (Toggle) self.fav_header = wx.Panel(self.fav_section) self.fav_header.SetBackgroundColour(wx.Colour(240, 240, 240)) h_hbox = wx.BoxSizer(wx.HORIZONTAL) # Star Icon star_bmp = wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16)) self.fav_icon = wx.StaticBitmap(self.fav_header, bitmap=star_bmp) h_hbox.Add(self.fav_icon, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 5) self.fav_label = wx.StaticText(self.fav_header, label=self.get_txt("label_favorites")) self.fav_label.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)) h_hbox.Add(self.fav_label, 1, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 5) self.fav_toggle_btn = wx.Button(self.fav_header, label="▼" if self.fav_visible else "▲", size=(25, 25), style=wx.BU_EXACTFIT) self.fav_toggle_btn.Bind(wx.EVT_BUTTON, self.toggle_favorites) h_hbox.Add(self.fav_toggle_btn, 0, wx.ALL, 2) self.fav_header.SetSizer(h_hbox) self.fav_vbox.Add(self.fav_header, 0, wx.EXPAND) # Fav List self.fav_list = wx.ListCtrl(self.fav_section, style=wx.LC_REPORT | wx.LC_NO_HEADER | wx.BORDER_SUNKEN) self.fav_list.AssignImageList(self.image_list, wx.IMAGE_LIST_SMALL) self.fav_list.InsertColumn(0, "Name", width=250) self.fav_list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_favorite_activated) self.fav_list.Bind(wx.EVT_CONTEXT_MENU, self.on_favorite_right_click) self.fav_vbox.Add(self.fav_list, 1, wx.EXPAND) self.left_vbox.Add(self.fav_section, 0, wx.EXPAND) if not self.fav_visible: self.fav_list.Hide() else: self.left_vbox.SetItemMinSize(self.fav_section, -1, 200) # Right side: File Area - ListCtrl self.list_ctrl = wx.ListCtrl(self.splitter, style=wx.LC_REPORT | wx.BORDER_SUNKEN) self.list_ctrl.AssignImageList(self.image_list, wx.IMAGE_LIST_SMALL) self.list_ctrl.InsertColumn(0, self.get_txt("col_name"), width=450) self.list_ctrl.InsertColumn(1, self.get_txt("col_type"), width=120) self.list_ctrl.InsertColumn(2, self.get_txt("col_size"), width=80) self.list_ctrl.InsertColumn(3, self.get_txt("col_modified"), width=180) self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated) self.list_ctrl.Bind(wx.EVT_LIST_COL_CLICK, self.on_column_click) self.list_ctrl.Bind(wx.EVT_CONTEXT_MENU, self.on_right_click) # AKTIVER DRAG & DROP self.list_ctrl.SetDropTarget(UploadDropTarget(self.list_ctrl, self)) self.splitter.SplitVertically(self.left_container, self.list_ctrl, 250) self.splitter.SetMinimumPaneSize(100) vbox.Add(self.splitter, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Load initial favorites self.refresh_fav_list() # 4. STATUS BAR 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() panel.SetSizer(vbox) self.Bind(wx.EVT_SIZE, self.on_resize) self.Layout() def on_right_click(self, event): selected_indices = [] idx = self.list_ctrl.GetFirstSelected() while idx != -1: selected_indices.append(idx) idx = self.list_ctrl.GetNextSelected(idx) menu = wx.Menu() if selected_indices: # Menu for de valgte emner selected_items = [self.current_items[i] for i in selected_indices] if len(selected_indices) == 1: item = selected_items[0] added_fav = False if item['type'] in ["FOLDER", "DRIVE", "SITE"]: fav_item = menu.Append(wx.ID_ANY, self.get_txt("btn_add_fav")) fav_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e, i=item: self.add_favorite(i), fav_item) added_fav = True added_file_action = False if item['type'] == "FILE": if added_fav: menu.AppendSeparator() edit_item = menu.Append(wx.ID_ANY, self.get_txt("msg_edit_file")) edit_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_EDIT, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e, i=item: self.open_file(i), edit_item) added_file_action = True added_folder_action = False if item['type'] in ["FILE", "FOLDER"]: if added_fav and not added_file_action: menu.AppendSeparator() rename_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_rename')} '{item['name']}'") rename_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REPORT_VIEW, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: self.on_rename_clicked(item), rename_item) delete_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} '{item['name']}'") delete_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DELETE, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_item) added_folder_action = True # Åbn i browser if item.get('web_url'): if added_fav or added_file_action or added_folder_action: menu.AppendSeparator() web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser")) web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e, url=item['web_url']: webbrowser.open(url), web_item) else: # Flere emner valgt delete_items = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} {len(selected_indices)} " + ("emner" if self.lang == "da" else "items")) delete_items.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DELETE, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_items) else: # Menu for selve mappen (hvis man trykker på det tomme felt) if self.current_web_url: web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser")) web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(self.current_web_url), web_item) menu.AppendSeparator() if self.current_drive_id: upload_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_here")) upload_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, self.on_upload_clicked, upload_item) upload_dir_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_folder_here")) upload_dir_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FOLDER_OPEN, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, self.on_upload_folder_clicked, upload_dir_item) new_folder_item = menu.Append(wx.ID_ANY, self.get_txt("msg_new_folder_here")) new_folder_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_NEW_DIR, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, self.on_new_folder_clicked, new_folder_item) # Tilføj altid opdater punkt til sidst if menu.GetMenuItemCount() > 0: menu.AppendSeparator() refresh_item = menu.Append(wx.ID_ANY, self.get_txt("btn_refresh")) refresh_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, self.on_refresh, refresh_item) self.PopupMenu(menu) menu.Destroy() def on_tree_right_click(self, event): item = event.GetItem() if not item.IsOk() or item == self.tree_root: return self.tree_ctrl.SelectItem(item) data = self.tree_item_data.get(item) menu = wx.Menu() if data: fav_item = menu.Append(wx.ID_ANY, self.get_txt("btn_add_fav")) fav_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e, d=data: self.add_favorite(d), fav_item) if data.get('web_url'): menu.AppendSeparator() web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser")) web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e, url=data['web_url']: webbrowser.open(url), web_item) menu.AppendSeparator() refresh_item = menu.Append(wx.ID_ANY, self.get_txt("btn_refresh")) refresh_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, self.on_refresh, refresh_item) self.PopupMenu(menu) menu.Destroy() # --- FAVORITES LOGIC --- def add_favorite(self, item): # Check if exists for fav in self.favorites: if fav['id'] == item['id']: self.show_info(self.get_txt("msg_fav_exists", name=item['name']), wx.ICON_INFORMATION) return new_fav = { "id": item['id'], "name": item['name'], "type": item['type'], "drive_id": item.get('drive_id'), "site_id": item.get('id') if item['type'] == "SITE" else self.current_site_id, "path": self.current_path + [item['name']], "web_url": item.get('web_url') } self.favorites.append(new_fav) self.save_favorites() self.refresh_fav_list() self.show_info(self.get_txt("msg_success")) def remove_favorite(self, id): self.favorites = [f for f in self.favorites if f['id'] != id] self.save_favorites() self.refresh_fav_list() def save_favorites(self): settings["favorites"] = self.favorites save_settings(settings) def refresh_fav_list(self): self.fav_list.DeleteAllItems() for i, fav in enumerate(self.favorites): img_idx = self.idx_star if fav['type'] == "DRIVE": img_idx = self.idx_drive elif fav['type'] == "SITE": img_idx = self.idx_site elif fav['type'] == "FOLDER": img_idx = self.idx_folder self.fav_list.InsertItem(i, fav['name'], img_idx) self.fav_list.SetItemData(i, i) # Store index def on_favorite_activated(self, event): idx = event.GetIndex() if idx < 0 or idx >= len(self.favorites): return fav = self.favorites[idx] # Opret navigations-data fra favoritten data = { "type": fav['type'], "id": fav['id'], "name": fav['name'], "drive_id": fav['drive_id'], "path": fav['path'], "web_url": fav.get('web_url') } # Vis indholdet og opdater stien self._navigate_to_item_data(data) # Forsøg at synkronisere træet asynkront wx.CallAfter(self._sync_tree_selection_by_path, fav['path']) def on_favorite_right_click(self, event): idx = self.fav_list.GetFirstSelected() if idx < 0: return fav = self.favorites[idx] menu = wx.Menu() if fav.get('web_url'): web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser")) web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(fav['web_url']), web_item) menu.AppendSeparator() remove_item = menu.Append(wx.ID_ANY, self.get_txt("btn_remove_fav")) remove_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DEL_BOOKMARK, wx.ART_MENU, (16, 16))) self.Bind(wx.EVT_MENU, lambda e: self.remove_favorite(fav['id']), remove_item) self.PopupMenu(menu) menu.Destroy() def toggle_favorites(self, event=None): self.fav_visible = not self.fav_visible self.fav_toggle_btn.SetLabel("▼" if self.fav_visible else "▲") if self.fav_visible: self.fav_list.Show() self.left_vbox.SetItemMinSize(self.fav_section, -1, 200) else: self.fav_list.Hide() self.left_vbox.SetItemMinSize(self.fav_section, -1, 30) settings["fav_visible"] = self.fav_visible save_settings(settings) self.left_container.Layout() # --- FILHÅNDTERING (Upload, Slet, Ny Mappe) --- def on_delete_items_clicked(self, items): if not items: return names = ", ".join([f"'{i['name']}'" for i in items[:3]]) if len(items) > 3: names += f" og {len(items)-3} andre..." if self.lang == "da" else f" and {len(items)-3} others..." msg = self.get_txt("msg_confirm_delete_multi", num=len(items), names=names) res = wx.MessageBox(msg, self.get_txt("msg_delete_title"), wx.YES_NO | wx.ICON_WARNING) if res == wx.YES: threading.Thread(target=self._delete_multiple_bg, args=(items,), daemon=True).start() def _delete_multiple_bg(self, items): if not self.ensure_valid_token(): return self.lock_ui(True) count = 0 total = len(items) for item in items: status_text = "Sletter" if self.lang == "da" else "Deleting" self.set_status(f"{status_text} {count+1}/{total}: '{item['name']}'...") url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}" res = _graph_request("DELETE", url, headers=self.headers, timeout=30) if res.status_code in [204, 200]: count += 1 else: self.set_status(self.get_txt("msg_delete_failed", name=item['name'])) wx.CallAfter(wx.MessageBox, f"Error deleting '{item['name']}': {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR) break self._refresh_current_view() self.lock_ui(False) self.set_status(self.get_txt("msg_deleted_status", count=count, total=total)) def on_upload_clicked(self, event): if not self.current_drive_id: return with wx.FileDialog(self, self.get_txt("msg_select_file"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fd: if fd.ShowModal() == wx.ID_OK: path = fd.GetPath() threading.Thread(target=self._upload_file_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start() def _upload_file_bg(self, local_path, drive_id, parent_id): if not self.ensure_valid_token(): return filename = os.path.basename(local_path) self.set_status(self.get_txt("msg_uploading", name=filename)) # Simpel upload (virker op til 4MB) url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content" try: with open(local_path, 'rb') as f: res = requests.put(url, headers=self.headers, data=f, timeout=120) if res.status_code in [200, 201]: self.set_status(self.get_txt("msg_upload_success", name=filename)) self._refresh_current_view() else: self.set_status(self.get_txt("msg_upload_failed", name=filename, code=res.status_code)) wx.CallAfter(wx.MessageBox, self.get_txt("msg_upload_failed", name=filename, code=res.status_code), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR) except Exception as e: self.set_status(f"Upload error: {e}") def on_upload_folder_clicked(self, event): if not self.current_drive_id: return with wx.DirDialog(self, self.get_txt("msg_select_folder"), style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) as dd: if dd.ShowModal() == wx.ID_OK: path = dd.GetPath() threading.Thread(target=self._upload_folder_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start() def _upload_folder_bg(self, local_dir, drive_id, parent_id): if not self.ensure_valid_token(): return dirname = os.path.basename(local_dir) self.set_status(self.get_txt("msg_creating_folder", name=dirname)) # 1. Opret mappen på SharePoint folder_id = self._create_folder_sync(dirname, drive_id, parent_id) if not folder_id: return # 2. Upload filer i mappen for item in os.listdir(local_dir): full_path = os.path.join(local_dir, item) if os.path.isfile(full_path): self._upload_file_bg_sync(full_path, drive_id, folder_id) elif os.path.isdir(full_path): self._upload_folder_bg(full_path, drive_id, folder_id) # Rekursivt self.set_status(self.get_txt("msg_folder_done", name=dirname)) self._refresh_current_view() def _create_folder_sync(self, name, drive_id, parent_id): url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}/children" body = {"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "rename"} res = _graph_request("POST", url, headers=self.headers, json=body, timeout=30) if res.status_code in [200, 201]: return res.json().get('id') return None def _upload_file_bg_sync(self, local_path, drive_id, parent_id): # Hjælper til sync upload brugt af mappe-upload filename = os.path.basename(local_path) url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content" with open(local_path, 'rb') as f: requests.put(url, headers=self.headers, data=f, timeout=120) def on_new_folder_clicked(self, event): if not self.current_drive_id: return dlg = wx.TextEntryDialog(self, self.get_txt("msg_new_folder_prompt"), self.get_txt("msg_new_folder_title")) if dlg.ShowModal() == wx.ID_OK: name = dlg.GetValue() if name: threading.Thread(target=self._create_folder_bg, args=(name, self.current_drive_id, self.current_folder_id), daemon=True).start() dlg.Destroy() def _create_folder_bg(self, name, drive_id, parent_id): if not self.ensure_valid_token(): return self.set_status(self.get_txt("msg_creating_folder", name=name)) folder_id = self._create_folder_sync(name, drive_id, parent_id) if folder_id: self.set_status(self.get_txt("msg_success")) self._refresh_current_view() else: self.set_status(self.get_txt("msg_error")) def on_rename_clicked(self, item): dlg = wx.TextEntryDialog(self, self.get_txt("msg_rename_prompt", name=item['name']), self.get_txt("msg_rename_title"), item['name']) if dlg.ShowModal() == wx.ID_OK: new_name = dlg.GetValue() if new_name and new_name != item['name']: threading.Thread(target=self._rename_item_bg, args=(item, new_name), daemon=True).start() dlg.Destroy() def _rename_item_bg(self, item, new_name): if not self.ensure_valid_token(): return self.set_status(f"{self.get_txt('msg_rename')}...") url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}" body = {"name": new_name} res = _graph_request("PATCH", url, headers=self.headers, json=body, timeout=30) if res.status_code in [200, 201]: self.set_status(self.get_txt("msg_success")) self._refresh_current_view() else: self.set_status(self.get_txt("msg_error")) wx.CallAfter(wx.MessageBox, f"Rename failed: {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR) def on_download_clicked(self, item): if not self.ensure_valid_token(): return if item['type'] == "FILE": with wx.FileDialog(self, self.get_txt("msg_select_file"), defaultFile=item['name'], style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as fd: if fd.ShowModal() == wx.ID_OK: path = fd.GetPath() threading.Thread(target=self._download_file_bg_task, args=(item, path), daemon=True).start() else: # Mappe eller Drev with wx.DirDialog(self, self.get_txt("msg_select_folder"), style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) as dd: if dd.ShowModal() == wx.ID_OK: parent_path = dd.GetPath() dest_path = os.path.join(parent_path, os.path.basename(item['name'])) threading.Thread(target=self._download_folder_bg_task, args=(item, dest_path), daemon=True).start() def _download_file_bg_task(self, item, dest_path): if not self.ensure_valid_token(): return self.set_status(self.get_txt("msg_downloading_to", name=item['name'], path=dest_path)) if self._download_file_sync_call(item['drive_id'], item['id'], dest_path): remote_hash = item.get('hash') if remote_hash and not verify_integrity(dest_path, remote_hash): self.show_info(f"Advarsel: Hash mismatch ved download af '{item['name']}'", wx.ICON_WARNING) self.set_status(self.get_txt("msg_download_done", name=item['name'])) else: self.set_status(self.get_txt("msg_error")) def _download_file_sync_call(self, drive_id, item_id, dest_path): url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{item_id}/content" res = _graph_request("GET", url, headers=self.headers, timeout=30) if res.status_code == 200: with open(dest_path, 'wb') as f: f.write(res.content) return True return False def _download_folder_bg_task(self, item, dest_path): if not self.ensure_valid_token(): return self.set_status(self.get_txt("msg_downloading_to", name=item['name'], path=dest_path)) self._download_folder_recursive_sync(item['drive_id'], item['id'], dest_path) self.set_status(self.get_txt("msg_download_done", name=item['name'])) def _download_folder_recursive_sync(self, drive_id, folder_id, local_dir): if not os.path.exists(local_dir): os.makedirs(local_dir) url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children" while url: res = _graph_request("GET", url, headers=self.headers, timeout=30) 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, os.path.basename(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, 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() rect = self.status_bar.GetFieldRect(1) self.gauge.SetPosition((rect.x + 5, rect.y + 2)) self.gauge.SetSize((rect.width - 10, rect.height - 4)) 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: self.info_timer.Start(4000, oneShot=True) else: self.info_timer.Stop() wx.CallAfter(_do) def on_done_editing_clicked(self, event): with self._edits_lock: waiting_files = [fid for fid, d in self.active_edits.items() if d.get("waiting")] if not waiting_files: return if len(waiting_files) == 1: fid = waiting_files[0] self.active_edits[fid]["event"].set() else: # Show menu to let user pick which file is finished menu = wx.Menu() for fid in waiting_files: name = self.active_edits[fid]["name"] item = menu.Append(wx.ID_ANY, f"Gem '{name}'") # closure to capture fid def make_handler(f_id): return lambda e: self.active_edits[f_id]["event"].set() self.Bind(wx.EVT_MENU, make_handler(fid), item) menu.AppendSeparator() item_all = menu.Append(wx.ID_ANY, "Gem alle") def handle_all(e): for f in waiting_files: if f in self.active_edits: self.active_edits[f]["event"].set() self.Bind(wx.EVT_MENU, handle_all, item_all) self.PopupMenu(menu) menu.Destroy() def on_settings_clicked(self, event): dlg = SettingsDialog(self, settings) if dlg.ShowModal() == wx.ID_OK: global CLIENT_ID, TENANT_ID, AUTHORITY, TEMP_DIR, CURRENT_LANG new_settings = dlg.settings # Check if IDs changed (need refresh) ids_changed = (new_settings["client_id"] != settings["client_id"] or new_settings["tenant_id"] != settings["tenant_id"]) # Save save_settings(new_settings) # Update global variables for current session settings.update(new_settings) CLIENT_ID = settings["client_id"] TENANT_ID = settings["tenant_id"] AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" TEMP_DIR = settings["temp_dir"] # Apply language if CURRENT_LANG != new_settings["language"]: CURRENT_LANG = new_settings["language"] self.lang = CURRENT_LANG self.refresh_ui_texts() # Update MSAL App if IDs changed if ids_changed: self.msal_app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY) self.access_token = None self.headers = {} self.login_btn.Enable() self.login_btn.SetLabel(self.get_txt("btn_login")) self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 100)) self.show_info(self.get_txt("msg_restart_required"), wx.ICON_INFORMATION) # Ensure temp dir exists if not os.path.exists(TEMP_DIR): os.makedirs(TEMP_DIR) self.show_info(self.get_txt("msg_settings_saved")) dlg.Destroy() def refresh_ui_texts(self): # Update UI texts for main frame and buttons self.SetTitle(self.get_txt("title")) # Hvis vi er i compact mode, skal vi ikke sætte labels på knapperne nu if not self.compact_mode: self._update_button_labels(full=True) self.settings_btn.SetLabel(self.get_txt("btn_settings") if not self.compact_mode else "") if self.access_token: self.login_btn.SetLabel(self.get_txt("btn_logged_in") if not self.compact_mode else "") else: self.login_btn.SetLabel(self.get_txt("btn_login") if not self.compact_mode else "") # Re-set headers for ListCtrl cols = [self.get_txt("col_name"), self.get_txt("col_type"), self.get_txt("col_size"), self.get_txt("col_modified")] for i, text in enumerate(cols): info = self.list_ctrl.GetColumn(i) info.SetText(text) info.SetImage(-1) self.list_ctrl.SetColumn(i, info) self.set_status(self.get_txt("status_ready")) self._refresh_current_view() def on_language_changed(self, event): # Deprecated: use on_settings_clicked instead if you want or keep for quick switch pass # We'll just remove or redirect it later def on_close_window(self, event): if self.active_edits: self.show_info(self.get_txt("msg_edit_warning"), wx.ICON_WARNING, auto_hide=False) return event.Skip() def lock_ui(self, lock=True): def _do(): self.tree_ctrl.Enable(not lock) self.list_ctrl.Enable(not lock) self.back_btn.Enable(not lock if len(self.history) > 1 else False) self.home_btn.Enable(not lock) self.refresh_btn.Enable(not lock) self.login_btn.Enable(not lock) wx.CallAfter(_do) def on_refresh(self, event=None): selected = self.tree_ctrl.GetSelection() if not selected.IsOk() or selected == self.tree_root: self.load_sites() return data = self.tree_item_data.get(selected) if data: self.set_status(f"Opdaterer '{data['name']}'...") # Opdater Listekontrol (højre side) threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() # Opdater Træstruktur (venstre side) - Ryd og indlæs igen hvis det er en container if data['type'] in ["SITE", "DRIVE", "FOLDER"]: data["loaded"] = False # Tillad genindlæsning if self.tree_ctrl.IsExpanded(selected): # Genindlæs træets børn asynkront threading.Thread(target=self._fetch_tree_children_bg, args=(selected, data), daemon=True).start() def _refresh_current_view(self): # 1. Prøv at bruge den aktuelle navigationskontekst (pålideligt ved Favorites/Breadcrumbs) if hasattr(self, 'current_path_data') and self.current_path_data: data = self.current_path_data[-1] self.set_status(f"Opdaterer '{data['name']}'...") threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() return # 2. Fallback til Træ-kontrol markering sel = self.tree_ctrl.GetSelection() if sel.IsOk(): data = self.tree_item_data.get(sel) if data: self.set_status(f"Opdaterer '{data['name']}'...") threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() def clear_main(self): self.list_ctrl.DeleteAllItems() self.current_items = [] self.update_path_display() def on_resize(self, event): width = self.GetSize().width threshold = 1100 if width < threshold and not self.compact_mode: self.compact_mode = True self._update_button_labels(full=False) elif width >= threshold and self.compact_mode: self.compact_mode = False self._update_button_labels(full=True) 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: # Liste over knapper og deres tilhørende oversættelses-nøgle btns = [ (self.back_btn, "btn_back", 40, 110), (self.home_btn, "btn_home", 40, 110), (self.refresh_btn, "btn_refresh", 40, 110), (self.upload_btn, "btn_upload_file", 40, 130), (self.upload_folder_btn, "btn_upload_folder", 40, 130), (self.new_folder_btn, "btn_new_folder", 40, 120), (self.settings_btn, "btn_settings", 40, 130) ] for btn, key, compact_w, full_w in btns: txt = self.get_txt(key) btn.SetLabel(txt if full else "") btn.SetToolTip(txt) btn.SetMinSize((compact_w if not full else full_w, 30)) btn.SetSize((compact_w if not full else full_w, 30)) btn.SetBitmapMargins((12 if full else 10, 0)) # Special cases for Login and Done buttons if full: login_txt = self.get_txt("btn_logged_in") if self.access_token else self.get_txt("btn_login") self.login_btn.SetLabel(login_txt) self.login_btn.SetToolTip(login_txt) self.login_btn.SetMinSize((130, 30)) self.login_btn.SetBitmapMargins((12, 0)) done_txt = self.get_txt("btn_save_changes") self.done_btn.SetLabel(done_txt) self.done_btn.SetToolTip(done_txt) self.done_btn.SetMinSize((250, 30)) self.done_btn.SetBitmapMargins((12, 0)) else: login_txt = self.get_txt("btn_logged_in") if self.access_token else self.get_txt("btn_login") self.login_btn.SetLabel("") self.login_btn.SetToolTip(login_txt) self.login_btn.SetMinSize((40, 30)) self.login_btn.SetBitmapMargins((10, 0)) done_txt = self.get_txt("btn_save_changes") self.done_btn.SetLabel("") self.done_btn.SetToolTip(done_txt) self.done_btn.SetMinSize((40, 30)) self.done_btn.SetBitmapMargins((10, 0)) self.nav_panel.Layout() self.Layout() except RuntimeError: pass def update_path_display(self): if not self: return try: self.path_sizer.Clear(True) self._add_path_segment(self.get_txt("title"), "ROOT") # Vis stien fra self.current_path_data for data in self.current_path_data: arrow = wx.StaticText(self.path_panel, label=" > ") arrow.SetForegroundColour(wx.Colour(150, 150, 150)) self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL) self._add_path_segment(data['name'], data) self.path_panel.Layout() self.path_panel.Refresh() self.Layout() except RuntimeError: pass def _add_path_segment(self, label, data): btn = wx.Button(self.path_panel, label=label, style=wx.BU_EXACTFIT | wx.BORDER_NONE) btn.SetBackgroundColour(wx.WHITE) btn.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) if data == "ROOT": btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_CMN_DIALOG, (16, 16))) btn.SetBitmapMargins((4, 0)) btn.Bind(wx.EVT_BUTTON, self.load_sites) elif data: def on_click(e, d=data): self._navigate_to_item_data(d) # Efter navigation, prøv at finde og vælge den i træet wx.CallAfter(self._sync_tree_selection_by_path, d["path"]) btn.Bind(wx.EVT_BUTTON, on_click) self.path_sizer.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) def ensure_valid_token(self): """Sikrer at vi har et gyldigt token. Returnerer True hvis OK.""" try: accounts = self.msal_app.get_accounts() if not accounts: self.set_status(self.get_txt("status_login_needed")) return False result = self.msal_app.acquire_token_silent(SCOPES, account=accounts[0]) if result and "access_token" in result: self.access_token = result["access_token"] self.headers = {'Authorization': f'Bearer {self.access_token}'} return True except Exception as e: logger.error(f"Token refresh error: {e}") self.set_status(self.get_txt("status_login_needed")) return False def login(self, event): self.set_status(self.get_txt("status_logging_in")) accounts = self.msal_app.get_accounts() result = None if accounts: result = self.msal_app.acquire_token_silent(SCOPES, account=accounts[0]) if not result or "access_token" not in result: result = self.msal_app.acquire_token_interactive(scopes=SCOPES) if "access_token" in result: self.access_token = result["access_token"] self.headers = {'Authorization': f'Bearer {self.access_token}'} self.login_btn.Disable() 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() self.refresh_btn.Enable() self.load_sites() else: self.set_status(self.get_txt("status_login_failed")) wx.CallAfter(wx.MessageBox, result.get("error_description", self.get_txt("msg_unknown_error")), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR) 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.current_path_data = [] 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 self.pulse_gauge(True) all_sites = [] url = "https://graph.microsoft.com/v1.0/sites?search=*" while url: res = _graph_request("GET", url, headers=self.headers, timeout=30) 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)}...)") 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.") 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) # 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, "") 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'), "path": ["SharePoint", name] }) 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 self.pulse_gauge(True) all_children = [] url = None 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" elif data['type'] == "FOLDER": url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children" while url: res = _graph_request("GET", url, headers=self.headers, timeout=30) if res.status_code == 200: res_data = res.json() all_children.extend(res_data.get('value', [])) url = res_data.get('@odata.nextLink') 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) parent_data["loaded"] = True target_node = None for drive in drives: name = drive.get('name', self.get_txt("type_unknown")) drive_id = drive['id'] node = self.tree_ctrl.AppendItem(parent_node, name, image=self.idx_drive) self.tree_item_data[node] = { "type": "DRIVE", "id": drive_id, "name": name, "drive_id": drive_id, "path": parent_data["path"] + [name], "loaded": False, "web_url": drive.get('webUrl') } self.tree_ctrl.SetItemHasChildren(node, True) if drive_id == getattr(self, '_pending_tree_selection_id', None): target_node = node if target_node: self._pending_tree_selection_id = None self.tree_ctrl.SelectItem(target_node) def _populate_tree_folders(self, parent_node, folders, parent_data): self.tree_ctrl.DeleteChildren(parent_node) parent_data["loaded"] = True target_node = None for folder in folders: name = folder['name'] folder_id = folder['id'] node = self.tree_ctrl.AppendItem(parent_node, name, image=self.idx_folder) self.tree_item_data[node] = { "type": "FOLDER", "id": folder_id, "name": name, "drive_id": parent_data["drive_id"], "path": parent_data["path"] + [name], "loaded": False, "web_url": folder.get('webUrl') } self.tree_ctrl.SetItemHasChildren(node, True) if folder_id == getattr(self, '_pending_tree_selection_id', None): target_node = node if target_node: self._pending_tree_selection_id = None self.tree_ctrl.SelectItem(target_node) def on_tree_selected(self, event): if not self: return item = event.GetItem() data = self.tree_item_data.get(item) if not data: return self._navigate_to_item_data(data, tree_item=item) def _navigate_to_item_data(self, data, tree_item=None): try: # Race-condition beskyttelse: Hvis vi allerede er der, så stop if getattr(self, 'current_path', None) == data.get("path"): return self.current_path = data["path"] # Opdater brødkrumme-data (vi gemmer segmenter EFTER SharePoint) new_len = len(self.current_path) if new_len > 1: # Trunker listen så den passer til den nye dybde (behold forældre) # Hvis stien er [S, A, D], skal data-stien være [A, D] (længde 2). # Før append skal vi have 1 element tilbage (indeks 0). self.current_path_data = self.current_path_data[:new_len-2] self.current_path_data.append(data) else: self.current_path_data = [] self.update_path_display() if tree_item and not self.is_navigating_back: # Undgå dubletter i historikken hvis vi allerede er der if not self.history or self.history[-1] != tree_item: self.history.append(tree_item) if self.back_btn: self.back_btn.Enable(len(self.history) > 1) self.list_ctrl.DeleteAllItems() self.current_items = [] self.set_status(self.get_txt("status_loading_content")) self._nav_gen += 1 gen = self._nav_gen threading.Thread(target=self._fetch_list_contents_bg, args=(data, gen), daemon=True).start() except RuntimeError: pass def _fetch_list_contents_bg(self, data, nav_gen=None): 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 = _graph_request("GET", url, headers=self.headers, timeout=30) 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] }) else: is_folder = 'folder' in item modified = item.get('lastModifiedDateTime', '').replace('T', ' ').split('.')[0] chunk_data.append({ "type": "FOLDER" if is_folder else "FILE", "id": item['id'], "name": item['name'], "drive_id": drive_id, "modified": modified, "size": item.get('size') if not is_folder else None, "web_url": item.get('webUrl'), "hash": item.get('file', {}).get('hashes', {}).get('quickXorHash') if not is_folder else None, "path": data['path'] + [item['name']] }) items_data.extend(chunk_data) self.set_status(self.get_txt("status_loading_items").format(count=len(items_data))) # 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, nav_gen) 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'] == "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, nav_gen=None): if not self: return if nav_gen is not None and nav_gen != self._nav_gen: return # User navigated away; discard stale results 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() if col == self.sort_col: self.sort_asc = not self.sort_asc else: self.sort_col = col self.sort_asc = True self.apply_sorting() def apply_sorting(self): if not self.current_items: return # Priority: SITE < DRIVE < FOLDER < FILE type_prio = {"SITE": 0, "DRIVE": 1, "FOLDER": 2, "FILE": 3} def sort_logic(item): # Altid grupper efter type først (mapper øverst) p = type_prio.get(item['type'], 9) val = "" if self.sort_col == 0: # Name val = natural_sort_key(item['name']) elif self.sort_col == 1: # Type val = item['type'] elif self.sort_col == 2: # Size val = item['size'] if item['size'] is not None else -1 elif self.sort_col == 3: # Modified val = item['modified'] return (p, val) self.current_items.sort(key=sort_logic, reverse=not self.sort_asc) self._update_list_view_only() def get_icon_idx_for_file(self, filename): ext = os.path.splitext(filename)[1].lower() if not ext or ext == ".": return self.idx_file if ext in self.ext_icons: return self.ext_icons[ext] # Prøv native Windows Shell API (SHGetFileInfo) if os.name == 'nt': try: # Sæt argtypes så vi er sikre på typerne shell32 = ctypes.windll.shell32 shell32.SHGetFileInfoW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(SHFILEINFO), wintypes.UINT, wintypes.UINT] shell32.SHGetFileInfoW.restype = wintypes.DWORD sfi = SHFILEINFO() # Brug et dummy-filnavn fremfor blot endelsen (sikrer bedre match på tværs af Windows versioner) dummy_file = "C:\\file" + ext # Eksplicit unicode buffer for at undgå konverteringsfejl path_buf = ctypes.create_unicode_buffer(dummy_file) res = shell32.SHGetFileInfoW(path_buf, 0x80, ctypes.byref(sfi), ctypes.sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON | SHGFI_USEFILEATTRIBUTES) if res and sfi.hIcon: # Mest kompatible måde at få en bitmap fra HICON i wxPython bmp = wx.Bitmap.FromHICON(sfi.hIcon) if bmp.IsOk(): # Sørg for at den er 16x16 if bmp.GetWidth() != 16 or bmp.GetHeight() != 16: img = bmp.ConvertToImage() bmp = wx.Bitmap(img.Scale(16, 16, wx.IMAGE_QUALITY_HIGH)) idx = self.image_list.Add(bmp) # Ryd op i handle med det samme ctypes.windll.user32.DestroyIcon(sfi.hIcon) self.ext_icons[ext] = idx return idx ctypes.windll.user32.DestroyIcon(sfi.hIcon) except Exception as e: pass # Gammelt fallback til MimeTypesManager (hvis SHGetFileInfo fejler) try: with wx.LogNull(): ft = wx.TheMimeTypesManager.GetFileTypeFromExtension(ext[1:] if ext.startswith('.') else ext) if ft: info = ft.GetIconInfo() if info: icon = info[0] if isinstance(info, tuple) else info.GetIcon() if icon and icon.IsOk(): bmp = wx.Bitmap(icon) if bmp.GetWidth() != 16 or bmp.GetHeight() != 16: img = bmp.ConvertToImage() bmp = wx.Bitmap(img.Scale(16, 16, wx.IMAGE_QUALITY_HIGH)) idx = self.image_list.Add(bmp) self.ext_icons[ext] = idx return idx except Exception: pass self.ext_icons[ext] = self.idx_file return self.idx_file # Fallback self.ext_icons[ext_clean] = self.idx_file return self.idx_file def _update_list_view_only(self): self.list_ctrl.DeleteAllItems() for i, item in enumerate(self.current_items): 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(i, 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(i, 1, type_str) size_str = format_size(item['size']) if item['size'] is not None else "" self.list_ctrl.SetItem(i, 2, size_str) self.list_ctrl.SetItem(i, 3, item['modified']) # Opdater kolonne ikoner for col in range(4): info = self.list_ctrl.GetColumn(col) if col == self.sort_col: info.SetImage(self.idx_up if self.sort_asc else self.idx_down) else: info.SetImage(-1) self.list_ctrl.SetColumn(col, info) def _populate_list_ctrl(self, items_data, parent_data, finalize=True): if not self: return try: self.current_items = items_data 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 (kun første gang) if parent_data: 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 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 def _safe_update_buttons(self, can_upload): try: if not self: return self.upload_btn.Show(can_upload) self.upload_folder_btn.Show(can_upload) self.new_folder_btn.Show(can_upload) self.Layout() except RuntimeError: pass def _sync_tree_selection_by_path(self, path): """Forsøg at finde og vælge en node i træet baseret på en sti af navne.""" if not path or path[0] != "SharePoint": return segments = path[1:] curr_node = self.tree_root for name in segments: found = False child, cookie = self.tree_ctrl.GetFirstChild(curr_node) while child.IsOk(): if self.tree_ctrl.GetItemText(child) == name: curr_node = child found = True break child, cookie = self.tree_ctrl.GetNextChild(curr_node, cookie) if not found: return # Stop hvis stien ikke findes/er indlæst i træet # Hvis vi nåede hertil, har vi fundet den korrekte node self.tree_ctrl.SelectItem(curr_node) self.tree_ctrl.EnsureVisible(curr_node) def on_item_activated(self, event): item_idx = event.GetIndex() item = self.current_items[item_idx] if item['type'] in ["SITE", "DRIVE", "FOLDER"]: # Prøv at finde og vælge den i træet (hvilket trigger on_tree_selected) if not self._sync_tree_selection(item['id']): # Hvis den allerede var valgt eller ikke findes i træet, tvinger vi navigationen self._navigate_to_item_data(item) elif item['type'] == "FILE": self.open_file(item) def _sync_tree_selection(self, target_id): selected = self.tree_ctrl.GetSelection() if not selected.IsOk(): selected = self.tree_root if selected.IsOk(): data = self.tree_item_data.get(selected) # Hvis vi allerede HAR valgt den rigtige node, så returner False (så on_item_activated tvinger refresh) if data and data['id'] == target_id: return False if data and not data.get("loaded"): self._pending_tree_selection_id = target_id self.tree_ctrl.Expand(selected) return True # Vi har sat en handling i gang child, cookie = self.tree_ctrl.GetFirstChild(selected) while child.IsOk(): cdata = self.tree_item_data.get(child) if cdata and cdata['id'] == target_id: if self.tree_ctrl.GetSelection() == child: return False # Allerede valgt self.tree_ctrl.SelectItem(child) return True # Selection changed child, cookie = self.tree_ctrl.GetNextChild(selected, cookie) return False def go_back(self, event=None): if len(self.history) > 1: self.history.pop() # Remove current prev_item = self.history[-1] # Peak at previous self.is_navigating_back = True self.tree_ctrl.SelectItem(prev_item) self.is_navigating_back = False def open_file(self, item): item_id = item['id'] file_name = item['name'] drive_id = item['drive_id'] with self._edits_lock: already_editing = item_id in self.active_edits at_limit = len(self.active_edits) >= 10 # UI dialogs are called outside the lock to avoid holding it during blocking calls if already_editing: self.show_info(f"'{file_name}' er allerede ved at blive redigeret.", wx.ICON_INFORMATION) return if at_limit: wx.MessageBox("Du kan kun have 10 filer åbne til redigering ad gangen.", "Maksimum grænse nået", wx.OK | wx.ICON_WARNING) return threading.Thread(target=self.process_file, args=(item_id, file_name, drive_id, item.get('hash')), daemon=True).start() def update_edit_ui(self): def _do(): try: if not self: return count = len(self.active_edits) waiting_count = sum(1 for d in self.active_edits.values() if d.get("waiting")) if waiting_count > 0: self.done_btn.SetLabel(f"{self.get_txt('btn_save_changes')} ({waiting_count})") self.done_btn.Show() else: self.done_btn.Hide() # Opdater statusbesked hvis der er aktive opgaver if count > 0: status_msg = f"Aktive filer: {count}" if waiting_count > 0: status_msg += f" ({waiting_count} venter på gem)" self.set_status(status_msg) else: self.set_status(self.get_txt("status_ready")) self.Layout() except RuntimeError: pass wx.CallAfter(_do) def process_file(self, item_id, file_name, drive_id, remote_hash=None): if not self.ensure_valid_token(): return edit_event = threading.Event() with self._edits_lock: self.active_edits[item_id] = {"name": file_name, "event": edit_event, "waiting": False} self.update_edit_ui() try: # 1. Lokation info base_url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{item_id}" # Unik undermappe baseret på ID, men brug originalt filnavn indeni item_hash = hashlib.md5(item_id.encode()).hexdigest()[:8] working_dir = os.path.join(TEMP_DIR, item_hash) if not os.path.exists(working_dir): os.makedirs(working_dir) local_path = os.path.join(working_dir, file_name) # 2. Download self.set_status(self.get_txt("msg_fetching_file", name=file_name)) res = _graph_request("GET", f"{base_url}/content", headers=self.headers, timeout=30) if res.status_code != 200: raise Exception(f"{self.get_txt('msg_unknown_error')}: {res.status_code}") with open(get_long_path(local_path), 'wb') as f: f.write(res.content) # Verificer integritet og gem hash til senere sammenligning original_hash = None if remote_hash and ENABLE_HASH_VALIDATION: file_size = os.path.getsize(get_long_path(local_path)) if file_size <= (HASH_THRESHOLD_MB * 1024 * 1024): # Vi bruger fjern-hash direkte som vores 'original', hvis den er tilgængelig. # Vi tjekker dog lige at downloaden rent faktisk matchede. local_check = quickxorhash(local_path) if local_check == remote_hash: original_hash = remote_hash logger.info(f"Download ok for {file_name}. Bruger XOR hash til ændrings-detektering.") else: logger.warning(f"Hash mismatch efter download af {file_name}!") self.show_info(f"Advarsel: Filens integritet kunne ikke bekræftes (XorHash mismatch)", wx.ICON_WARNING) original_hash = local_check # Hvis vi ikke beregnede hash pga. størrelse eller manglende remote_hash, gør det nu for lokal detektering if original_hash is None: # Her bruger vi SHA256 af hastighedsårsager til lokal sammenligning (før/efter) sha256 = hashlib.sha256() with open(get_long_path(local_path), 'rb') as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break sha256.update(chunk) original_hash = "SHA256:" + sha256.hexdigest() logger.info(f"Bruger lokal SHA256 til ændrings-detektering for {file_name}") # Checkout is_checked_out = False checkout_res = _graph_request("POST", f"{base_url}/checkout", headers=self.headers, timeout=30) if checkout_res.status_code in [200, 201, 204]: is_checked_out = True logger.info(f"Fil {file_name} udtjekket succesfuldt.") else: logger.warning(f"Kunne ikke udtjekke {file_name} (Status: {checkout_res.status_code}). Fortsætter dog...") # 3. Åbn & Overvåg self.set_status(self.get_txt("msg_opening_file", name=file_name)) os.startfile(local_path) locked = False self.set_status(self.get_txt("msg_waiting_for_file", name=file_name)) for _ in range(10): time.sleep(1) try: os.rename(local_path, local_path) except OSError: locked = True break if locked: self.set_status(self.get_txt("msg_editing_file", name=file_name)) while True: time.sleep(2) try: os.rename(local_path, local_path) break except OSError: pass else: self.set_status(self.get_txt("msg_waiting_for_file", name=file_name)) edit_event.clear() with self._edits_lock: self.active_edits[item_id]["waiting"] = True self.update_edit_ui() edit_event.wait() with self._edits_lock: if item_id in self.active_edits: self.active_edits[item_id]["waiting"] = False self.update_edit_ui() # 4. Tjek om noget er ændret if original_hash.startswith("SHA256:"): sha256 = hashlib.sha256() with open(get_long_path(local_path), 'rb') as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break sha256.update(chunk) new_hash = "SHA256:" + sha256.hexdigest() else: new_hash = quickxorhash(local_path) if original_hash == new_hash: logger.info(f"Ingen ændringer fundet i {file_name}. (Hash: {new_hash[:16]}...) Springer upload over.") self.set_status(self.get_txt("msg_file_unchanged")) if is_checked_out: logger.info(f"Annullerer udtjekning (discardCheckout) for {file_name}...") res = _graph_request("POST", f"{base_url}/discardCheckout", headers=self.headers, timeout=30) if res.status_code in [200, 204]: is_checked_out = False else: # 5. Upload (kun hvis ændret) logger.info(f"Ændring fundet! Uploader {file_name}...") self.set_status(self.get_txt("msg_updating_changes")) with open(local_path, 'rb') as f: upload_res = requests.put(f"{base_url}/content", headers=self.headers, data=f, timeout=120) if upload_res.status_code not in [200, 201]: raise Exception(f"{self.get_txt('msg_update_failed_code', code=upload_res.status_code)}") # 6. Checkin (Kun hvis vi faktisk uploadede noget) if is_checked_out: self.set_status(self.get_txt("msg_checking_in", name=file_name)) res = _graph_request("POST", f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"}, timeout=30) if res.status_code in [200, 201, 204]: is_checked_out = False # Oprydning: Slet fil og derefter mappe try: os.remove(local_path) os.rmdir(working_dir) except Exception: pass self.set_status(self.get_txt("msg_update_success", name=file_name)) self.show_info(self.get_txt("msg_update_success", name=file_name), wx.ICON_INFORMATION) self._refresh_current_view() except Exception as e: self.set_status(f"{self.get_txt('msg_error')}: {str(e)}") self.show_info(f"{self.get_txt('msg_error')}: {e}", wx.ICON_ERROR) finally: if is_checked_out: # Emergency cleanup hvis vi stadig har fat i filen (f.eks. ved crash eller afbrydelse) logger.info(f"Rydder op: Kalder discardCheckout for {file_name}...") _graph_request("POST", f"{base_url}/discardCheckout", headers=self.headers, timeout=30) with self._edits_lock: if item_id in self.active_edits: del self.active_edits[item_id] self.update_edit_ui() if __name__ == "__main__": app = wx.App() SharePointApp() app.MainLoop()