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 from ctypes import wintypes # --- 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, "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: 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) 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") # --- 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" }, "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" } } 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 def get_file_hash(path): if not os.path.exists(path): return None sha256_hash = hashlib.sha256() with open(path, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() 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=(520, 720)) 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) panel = wx.Panel(self) inner_vbox = wx.BoxSizer(wx.VERTICAL) # --- Group: Authentication --- auth_box = wx.StaticBox(panel, label=self.get_txt("settings_auth_group")) auth_sizer = wx.StaticBoxSizer(auth_box, wx.VERTICAL) grid = wx.FlexGridSizer(2, 2, 10, 10) grid.AddGrowableCol(1, 1) grid.Add(wx.StaticText(panel, label=self.get_txt("settings_client_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.client_id_ctrl = wx.TextCtrl(panel, value=self.settings.get("client_id", ""), size=(-1, 25)) grid.Add(self.client_id_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL) grid.Add(wx.StaticText(panel, label=self.get_txt("settings_tenant_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.tenant_id_ctrl = wx.TextCtrl(panel, value=self.settings.get("tenant_id", ""), size=(-1, 25)) grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL) auth_sizer.Add(grid, 1, wx.EXPAND | wx.ALL, 10) inner_vbox.Add(auth_sizer, 0, wx.EXPAND | wx.ALL, 10) # --- Group: Paths --- path_box = wx.StaticBox(panel, label=self.get_txt("settings_path_group")) path_sizer = wx.StaticBoxSizer(path_box, wx.VERTICAL) path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_temp_dir")), 0, wx.BOTTOM, 5) self.temp_dir_picker = wx.DirPickerCtrl(panel, path=self.settings.get("temp_dir", "C:\\Temp_SP"), style=wx.DIRP_DIR_MUST_EXIST) path_sizer.Add(self.temp_dir_picker, 0, wx.EXPAND | wx.BOTTOM, 10) path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_app_path")), 0, wx.BOTTOM, 5) app_path_box = wx.TextCtrl(panel, value=CONFIG_DIR, style=wx.TE_READONLY | wx.BORDER_NONE) app_path_box.SetBackgroundColour(panel.GetBackgroundColour()) path_sizer.Add(app_path_box, 0, wx.EXPAND | wx.BOTTOM, 10) path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_active_temp_path")), 0, wx.BOTTOM, 5) temp_path_box = wx.TextCtrl(panel, value=TEMP_DIR, style=wx.TE_READONLY | wx.BORDER_NONE) temp_path_box.SetBackgroundColour(panel.GetBackgroundColour()) path_sizer.Add(temp_path_box, 0, wx.EXPAND) inner_vbox.Add(path_sizer, 0, wx.EXPAND | wx.ALL, 10) # --- Group: License --- lic_box = wx.StaticBox(panel, label=self.get_txt("settings_license_group")) lic_sizer = wx.StaticBoxSizer(lic_box, wx.VERTICAL) lic_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_license_key")), 0, wx.BOTTOM, 5) self.license_ctrl = wx.TextCtrl(panel, value=self.settings.get("license_key", "")) lic_sizer.Add(self.license_ctrl, 0, wx.EXPAND | wx.BOTTOM, 5) status_txt = wx.StaticText(panel, label=self.get_txt("settings_license_status")) status_txt.SetForegroundColour(wx.RED) lic_sizer.Add(status_txt, 0, wx.TOP, 5) inner_vbox.Add(lic_sizer, 0, wx.EXPAND | wx.ALL, 10) # --- Group: Language --- lang_box = wx.StaticBox(panel, label=self.get_txt("settings_lang_group")) lang_sizer = wx.StaticBoxSizer(lang_box, wx.HORIZONTAL) lang_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) self.lang_choice = wx.Choice(panel, choices=["Dansk", "English"]) self.lang_choice.SetSelection(0 if self.settings.get("language") == "da" else 1) lang_sizer.Add(self.lang_choice, 1, wx.EXPAND) inner_vbox.Add(lang_sizer, 0, wx.EXPAND | wx.ALL, 10) panel.SetSizer(inner_vbox) inner_vbox.Fit(panel) vbox.Add(panel, 1, wx.EXPAND | wx.ALL, 0) # --- 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() 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 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 https://login.microsoftonline.com/common/oauth2/nativeclient\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' hvis nødvendigt.\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="Gem Indstillinger" if self.lang == "da" else "Save Settings", 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="Afslut" if self.lang == "da" else "Exit", 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.tree_root = None self.is_navigating_back = False self.active_edits = {} # item_id -> { "name": name, "event": Event, "waiting": bool } 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: print(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: 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() self.status_bar.SetStatusText(self.get_txt("status_ready")) 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] 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) menu.AppendSeparator() if item['type'] == "FILE": 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) if item['type'] in ["FILE", "FOLDER"]: 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) # Åbn i browser if item.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=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] self.current_path = fav['path'] self.update_path_display() # Navigate to contents data = { "type": fav['type'], "id": fav['id'], "drive_id": fav['drive_id'], "path": fav['path'] } if fav['type'] == "SITE": self.current_site_id = fav['id'] elif fav['drive_id']: self.current_drive_id = fav['drive_id'] self.list_ctrl.DeleteAllItems() self.current_items = [] self.set_status(self.get_txt("status_loading_content")) threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() 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 = requests.delete(url, headers=self.headers) 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) 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 = requests.post(url, headers=self.headers, json=body) 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) 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 = requests.patch(url, headers=self.headers, json=body) 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, 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): 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 = requests.get(url, headers=self.headers) 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" res = requests.get(url, headers=self.headers) if res.status_code == 200: items = res.json().get('value', []) for item in items: item_path = os.path.join(local_dir, item['name']) if 'folder' in item: self._download_folder_recursive_sync(drive_id, item['id'], item_path) else: self._download_file_sync_call(drive_id, item['id'], item_path) def set_status(self, text): wx.CallAfter(self.status_bar.SetStatusText, text) def show_info(self, text, type=wx.ICON_INFORMATION, auto_hide=True): def _do(): 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): 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): sel = self.tree_ctrl.GetSelection() if sel.IsOk(): data = self.tree_item_data.get(sel) if data: # Kør i nuværende baggrundstråd hvis muligt, ellers ny self._fetch_list_contents_bg(data) 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) 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 path_segments = self.current_path[1:] if self.current_path and self.current_path[0] == "SharePoint" else self.current_path # Prøv at finde matchende noder i træet for at gøre brødkrummerne klikbare curr_node = self.tree_root for name in path_segments: arrow = wx.StaticText(self.path_panel, label=" > ") arrow.SetForegroundColour(wx.Colour(150, 150, 150)) self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL) found_node = None if curr_node: child, cookie = self.tree_ctrl.GetFirstChild(curr_node) while child.IsOk(): if self.tree_ctrl.GetItemText(child) == name: found_node = child break child, cookie = self.tree_ctrl.GetNextChild(curr_node, cookie) self._add_path_segment(name, found_node) curr_node = found_node # Fortsæt ned i træet hvis muligt self.path_panel.Layout() self.path_panel.Refresh() self.Layout() except RuntimeError: pass def _add_path_segment(self, label, node): 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 node == "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 node: btn.Bind(wx.EVT_BUTTON, lambda e: self.tree_ctrl.SelectItem(node)) 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: print(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.Hide() # Valgfrit: Skjul login knap helt når vi er inde self.login_btn.SetLabel(self.get_txt("btn_logged_in")) 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.history = [] self.update_path_display() threading.Thread(target=self._fetch_sites_bg, daemon=True).start() def _fetch_sites_bg(self): if not self.ensure_valid_token(): return url = "https://graph.microsoft.com/v1.0/sites?search=*" res = requests.get(url, headers=self.headers) if res.status_code == 200: sites = res.json().get('value', []) sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower()) wx.CallAfter(self._populate_sites_tree, sites) else: self.set_status(self.get_txt("msg_unknown_error")) def _populate_sites_tree(self, sites): self.set_status(f"{len(sites)} sites.") for site in sites: name = site.get('displayName', site.get('name')) node = self.tree_ctrl.AppendItem(self.tree_root, name, image=self.idx_site) self.tree_item_data[node] = { "type": "SITE", "id": site['id'], "name": name, "drive_id": None, "path": ["SharePoint", name], "loaded": False, "web_url": site.get('webUrl') } self.tree_ctrl.SetItemHasChildren(node, True) # Select the first site or just show in list (defaulting to showing root sites in list) self.list_ctrl.DeleteAllItems() self.current_items = [] for i, site in enumerate(sites): name = site.get('displayName', site.get('name')) self.list_ctrl.InsertItem(i, name, self.idx_site) self.list_ctrl.SetItem(i, 1, self.get_txt("type_site")) self.list_ctrl.SetItem(i, 2, "") # Størrelse self.list_ctrl.SetItem(i, 3, "") # Sidst ændret self.current_items.append({ "type": "SITE", "id": site['id'], "name": name, "size": None, "modified": "", "web_url": site.get('webUrl') }) def on_tree_expanding(self, event): item = event.GetItem() data = self.tree_item_data.get(item) if not data or data.get("loaded"): return loading_node = self.tree_ctrl.AppendItem(item, self.get_txt("status_loading")) threading.Thread(target=self._fetch_tree_children_bg, args=(item, data), daemon=True).start() def _fetch_tree_children_bg(self, parent_node, data): if not self.ensure_valid_token(): return if data['type'] == "SITE": url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives" res = requests.get(url, headers=self.headers) if res.status_code == 200: drives = res.json().get('value', []) drives.sort(key=lambda x: x.get('name', '').lower()) wx.CallAfter(self._populate_tree_drives, parent_node, drives, data) elif data['type'] == "DRIVE": url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children" res = requests.get(url, headers=self.headers) if res.status_code == 200: items = res.json().get('value', []) folders = [x for x in items if 'folder' in x] folders.sort(key=lambda x: x['name'].lower()) wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) elif data['type'] == "FOLDER": url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children" res = requests.get(url, headers=self.headers) if res.status_code == 200: items = res.json().get('value', []) folders = [x for x in items if 'folder' in x] folders.sort(key=lambda x: x['name'].lower()) wx.CallAfter(self._populate_tree_folders, parent_node, folders, data) def _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 try: self.current_path = data["path"] self.update_path_display() if not self.is_navigating_back: self.history.append(item) # Check if button still exists 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")) threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() except RuntimeError: pass def _fetch_list_contents_bg(self, data): if not self.ensure_valid_token(): return items_data = [] if data['type'] == "SITE": url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives" res = requests.get(url, headers=self.headers) if res.status_code == 200: drives = res.json().get('value', []) drives.sort(key=lambda x: x.get('name', '').lower()) for drive in drives: items_data.append({ "type": "DRIVE", "id": drive['id'], "name": drive.get('name', ''), "drive_id": drive['id'], "modified": "", "size": None, "web_url": drive.get('webUrl') }) elif data['type'] in ["DRIVE", "FOLDER"]: drive_id = data['drive_id'] if data['type'] == "DRIVE": url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children" else: url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{data['id']}/children" res = requests.get(url, headers=self.headers) if res.status_code == 200: items = res.json().get('value', []) items.sort(key=lambda x: (not 'folder' in x, x['name'].lower())) for item in items: is_folder = 'folder' in item modified = item.get('lastModifiedDateTime', '').replace('T', ' ').split('.')[0] items_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') }) wx.CallAfter(self._populate_list_ctrl, items_data, data) 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: 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): if not self: return try: self.current_items = items_data # Anvend sortering før visning self.apply_sorting() # Opdater tilstand self.current_web_url = parent_data.get('web_url') if parent_data['type'] == "SITE": self.current_site_id = parent_data['id'] elif parent_data['type'] == "DRIVE": self.current_drive_id = parent_data['id'] self.current_folder_id = "root" elif parent_data['type'] == "FOLDER": self.current_drive_id = parent_data['drive_id'] self.current_folder_id = parent_data['id'] # Opdater knap-synlighed can_upload = self.current_drive_id is not None wx.CallAfter(lambda: self._safe_update_buttons(can_upload)) 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 on_item_activated(self, event): item_idx = event.GetIndex() item = self.current_items[item_idx] if item['type'] in ["SITE", "DRIVE", "FOLDER"]: self._sync_tree_selection(item['id']) 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) if data and not data.get("loaded"): self._pending_tree_selection_id = target_id self.tree_ctrl.Expand(selected) return child, cookie = self.tree_ctrl.GetFirstChild(selected) while child.IsOk(): cdata = self.tree_item_data.get(child) if cdata and cdata['id'] == target_id: self.tree_ctrl.SelectItem(child) return child, cookie = self.tree_ctrl.GetNextChild(selected, cookie) 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'] if item_id in self.active_edits: self.show_info(f"'{file_name}' er allerede ved at blive redigeret.", wx.ICON_INFORMATION) return if len(self.active_edits) >= 10: 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), 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): if not self.ensure_valid_token(): return edit_event = threading.Event() 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 = requests.get(f"{base_url}/content", headers=self.headers) if res.status_code != 200: raise Exception(f"{self.get_txt('msg_unknown_error')}: {res.status_code}") with open(local_path, 'wb') as f: f.write(res.content) # Beregn udgangspunkt hash original_hash = get_file_hash(local_path) # Checkout requests.post(f"{base_url}/checkout", headers=self.headers) # 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() self.active_edits[item_id]["waiting"] = True self.update_edit_ui() edit_event.wait() if item_id in self.active_edits: self.active_edits[item_id]["waiting"] = False self.update_edit_ui() # 4. Tjek om noget er ændret new_hash = get_file_hash(local_path) if original_hash == new_hash: self.set_status(self.get_txt("msg_file_unchanged")) else: # 5. Upload (kun hvis ændret) 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) 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 (Uanset om ændret eller ej, for at frigive lås) self.set_status(self.get_txt("msg_checking_in", name=file_name)) requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"}) # Oprydning: Slet fil og derefter mappe try: os.remove(local_path) os.rmdir(working_dir) except: 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 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()