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 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 = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY) 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.on_settings_clicked, None) 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()