import os import time import threading import hashlib import json import sys import requests import msal import wx import wx.lib.newevent # --- STIHÅNDTERING (Til EXE-brug) --- if getattr(sys, 'frozen', False): # Vi kører som en kompileret .exe base_dir = os.path.dirname(sys.executable) else: # Vi kører som normalt script base_dir = os.path.dirname(os.path.abspath(__file__)) SETTINGS_FILE = os.path.join(base_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 } 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_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_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." }, "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_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_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." } } if not os.path.exists(TEMP_DIR): os.makedirs(TEMP_DIR) 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" 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.is_editing = False # Låse-state ved filredigering # System Ikoner (ArtProvider - mest basale for kompatibilitet) self.image_list = wx.ImageList(16, 16) self.idx_site = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_OTHER, (16, 16))) # Site (Hus ikon) self.idx_drive = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_HARDDISK, wx.ART_OTHER, (16, 16))) # Drive self.idx_folder = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_FOLDER, wx.ART_OTHER, (16, 16))) # Folder self.idx_file = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, (16, 16))) # File # Threading/Sync til filredigering self.edit_wait_event = threading.Event() self.InitUI() self.Centre() self.Show() self.Bind(wx.EVT_CLOSE, self.on_close_window) 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="← Tilbage", size=(100, 30)) 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="🏠 Hjem", size=(100, 30)) 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) # NY KNAP: Gem ændringer (Vises kun ved redigering) self.done_btn = wx.Button(nav_panel, label="💾 Gem ændringer i SharePoint", size=(200, 30)) self.done_btn.SetBackgroundColour(wx.Colour(255, 69, 0)) # OrangeRed self.done_btn.SetForegroundColour(wx.WHITE) 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="📤 Upload Fil", size=(120, 30)) 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="📁 Upload Mappe", size=(120, 30)) 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="➕ Ny Mappe", size=(100, 30)) 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=(120, 30)) self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn self.login_btn.SetForegroundColour(wx.WHITE) self.login_btn.Bind(wx.EVT_BUTTON, self.login) nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10) # SPROG VÆLGER self.lang_choice = wx.Choice(nav_panel, choices=["Dansk", "English"]) self.lang_choice.SetSelection(0 if self.lang == "da" else 1) self.lang_choice.Bind(wx.EVT_CHOICE, self.on_language_changed) nav_hbox.Add(self.lang_choice, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10) nav_panel.SetSizer(nav_hbox) vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5) # 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 self.tree_ctrl = wx.TreeCtrl(self.splitter, 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) # 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, "Navn", width=450) self.list_ctrl.InsertColumn(1, "Type", width=120) self.list_ctrl.InsertColumn(2, "Størrelse", width=80) self.list_ctrl.InsertColumn(3, "Sidst ændret", width=180) self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated) 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.tree_ctrl, self.list_ctrl, 250) self.splitter.SetMinimumPaneSize(100) vbox.Add(self.splitter, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # 4. STATUS BAR self.status_bar = self.CreateStatusBar() self.status_bar.SetStatusText("Klar") panel.SetSizer(vbox) 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'] == "FILE": edit_item = menu.Append(wx.ID_ANY, self.get_txt("msg_edit_file")) self.Bind(wx.EVT_MENU, lambda e: threading.Thread(target=self.process_file, args=(item['id'], item['name']), daemon=True).start(), edit_item) if item['type'] in ["FILE", "FOLDER"]: rename_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_rename')} '{item['name']}'") 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']}'") self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_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")) 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_drive_id: upload_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_here")) 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")) 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")) self.Bind(wx.EVT_MENU, self.on_new_folder_clicked, new_folder_item) if menu.GetMenuItemCount() > 0: self.PopupMenu(menu) menu.Destroy() # --- 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): 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): 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): 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): 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): 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 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): print("[DEBUG] 'Jeg er færdig'-knap klikket.") self.set_status("Knap trykket - Uploader nu...") self.edit_wait_event.set() self.info_bar.Dismiss() def on_language_changed(self, event): selection = self.lang_choice.GetSelection() self.lang = "da" if selection == 0 else "en" # Gem til settings settings["language"] = self.lang save_settings(settings) # Opdater UI tekster med det samme self.SetTitle(self.get_txt("title")) self.back_btn.SetLabel(self.get_txt("btn_back")) self.home_btn.SetLabel(self.get_txt("btn_home")) self.done_btn.SetLabel(self.get_txt("btn_save_changes")) self.upload_btn.SetLabel(self.get_txt("btn_upload_file")) self.upload_folder_btn.SetLabel(self.get_txt("btn_upload_folder")) self.new_folder_btn.SetLabel(self.get_txt("btn_new_folder")) if self.access_token: self.login_btn.SetLabel(self.get_txt("btn_logged_in")) else: self.login_btn.SetLabel(self.get_txt("btn_login")) # Opdater kolonner i ListCtrl self.list_ctrl.SetColumnWidth(0, 450) # Refresh widths item = self.list_ctrl.GetColumn(0) self.list_ctrl.SetColumn(0, wx.ListItem()) # Reset column header logic could be complex in wx, # but the simplest is to just re-insert columns or set text # Re-set headers (Fix: explicitly set image to -1 to avoid icons in headers) 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() # Gendanner list-item tekster (Mappe/Fil) def on_close_window(self, event): if self.is_editing: 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.login_btn.Enable(not lock) wx.CallAfter(_do) 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 update_path_display(self): if not self: return try: self.path_sizer.Clear(True) # Find alle noder fra rod til nuværende selektion nodes = [] curr = self.tree_ctrl.GetSelection() while curr.IsOk() and curr != self.tree_root: nodes.insert(0, curr) curr = self.tree_ctrl.GetItemParent(curr) # Start ikon/label self._add_path_segment("📍 SharePoint", "ROOT") for node in nodes: arrow = wx.StaticText(self.path_panel, label=" > ") arrow.SetForegroundColour(wx.Colour(150, 150, 150)) self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL) name = self.tree_ctrl.GetItemText(node) self._add_path_segment(name, node) self.path_panel.Layout() self.path_panel.Refresh() self.Layout() # Tving rammen til at opdatere, så stien kommer frem except RuntimeError: # Sker oftest ved lukning hvor objekter er slettet 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.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 login(self, event): self.set_status(self.get_txt("status_logging_in")) app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY) accounts = app.get_accounts() result = None if accounts: result = app.acquire_token_silent(SCOPES, account=accounts[0]) if not result: result = app.acquire_token_interactive(scopes=SCOPES) if "access_token" in result: self.access_token = result["access_token"] self.headers = {'Authorization': f'Bearer {self.access_token}'} self.login_btn.Disable() self.login_btn.SetLabel(self.get_txt("btn_logged_in")) self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå self.home_btn.Enable() self.load_sites() else: self.set_status(self.get_txt("status_login_failed")) wx.MessageBox(result.get("error_description", "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): 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("Kunne ikke hente sites.") 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 } 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": ""}) 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 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', 'Ukendt') 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 } 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 } 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 or self.is_editing: 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): 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 }) 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 }) wx.CallAfter(self._populate_list_ctrl, items_data, data) def _populate_list_ctrl(self, items_data, parent_data): if not self: return try: self.list_ctrl.DeleteAllItems() self.current_items = [] for i, item in enumerate(items_data): 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 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']) self.current_items.append(item) self.set_status(self.get_txt("status_ready")) if parent_data['type'] == "SITE": self.current_site_id = parent_data['id'] elif parent_data['type'] == "DRIVE": self.current_drive_id = parent_data['id'] self.current_folder_id = "root" elif parent_data['type'] == "FOLDER": self.current_drive_id = parent_data['drive_id'] self.current_folder_id = parent_data['id'] # Opdater knap-synlighed can_upload = self.current_drive_id is not None wx.CallAfter(lambda: self._safe_update_buttons(can_upload)) 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): if self.is_editing: return 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.current_drive_id = item['drive_id'] threading.Thread(target=self.process_file, args=(item['id'], item['name']), daemon=True).start() 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 self.is_editing: return 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 process_file(self, item_id, file_name): self.is_editing = True self.lock_ui(True) try: # 1. Lokation info site_id = self.current_site_id drive_id = self.current_drive_id base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/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(f"Henter '{file_name}'...") res = requests.get(f"{base_url}/content", headers=self.headers) if res.status_code != 200: raise Exception(f"Kunne ikke hente fil: {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(f"Åbner '{file_name}'...") os.startfile(local_path) locked = False self.set_status(f"Venter på '{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(f"Redigerer '{file_name}' - Luk for at gemme.") while True: time.sleep(2) try: os.rename(local_path, local_path) break except OSError: pass else: self.set_status("Fillås ikke detekteret.") self.edit_wait_event.clear() wx.CallAfter(self.done_btn.Show) wx.CallAfter(self.Layout) self.edit_wait_event.wait() wx.CallAfter(self.done_btn.Hide) wx.CallAfter(self.Layout) # 4. Tjek om noget er ændret new_hash = get_file_hash(local_path) if original_hash == new_hash: self.set_status("Ingen ændringer fundet. Springer upload over.") else: # 5. Upload (kun hvis ændret) self.set_status(f"Uploader ændringer...") 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"Upload fejlede: {upload_res.status_code}") # 6. Checkin (Uanset om ændret eller ej, for at frigive lås) self.set_status(f"Tjekker '{file_name}' ind...") 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(f"Succes! '{file_name}' er opdateret.") self.show_info(f"Filen '{file_name}' er gemt og tjekket ind korrekt.", wx.ICON_INFORMATION) self._refresh_current_view() except Exception as e: self.set_status(f"Fejl: {str(e)}") self.show_info(f"Der skete en fejl: {e}", wx.ICON_ERROR) finally: self.is_editing = False self.lock_ui(False) if __name__ == "__main__": app = wx.App() SharePointApp() app.MainLoop()