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" } if not os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE, 'w') as f: json.dump(default_settings, f, indent=4) return default_settings with open(SETTINGS_FILE, 'r') as f: try: return json.load(f) except: return default_settings 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") if not os.path.exists(TEMP_DIR): os.makedirs(TEMP_DIR) class SharePointApp(wx.Frame): def __init__(self): super().__init__(None, title="SharePoint Explorer", 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 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) nav_hbox.AddStretchSpacer(1) self.login_btn = wx.Button(nav_panel, label="Log ind", 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) 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=150) self.list_ctrl.InsertColumn(2, "Sidst ændret", width=200) 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) 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): item_idx = self.list_ctrl.GetFirstSelected() if item_idx == -1: return item = self.current_items[item_idx] if item['type'] == "FILE": menu = wx.Menu() edit_item = menu.Append(wx.ID_ANY, "Rediger fil") self.Bind(wx.EVT_MENU, lambda e: threading.Thread(target=self.process_file, args=(item['id'], item['name']), daemon=True).start(), edit_item) self.PopupMenu(menu) menu.Destroy() 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_close_window(self, event): if self.is_editing: self.show_info("Du er i gang med at redigere en fil. Luk din editor og gem ændringerne før du lukker programmet.", 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): 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 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("Logger ind...") 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("Logget ind") self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå self.home_btn.Enable() self.load_sites() else: self.set_status("Login fejlede.") wx.MessageBox(result.get("error_description", "Unknown error"), "Login Error", wx.OK | wx.ICON_ERROR) def load_sites(self, event=None): self.set_status("Henter 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"Fandt {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, "Site") self.current_items.append({"type": "SITE", "id": site['id'], "name": name}) def on_tree_expanding(self, event): item = event.GetItem() data = self.tree_item_data.get(item) if not data or data.get("loaded"): return loading_node = self.tree_ctrl.AppendItem(item, "Indlæser...") 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 self.is_editing: return item = event.GetItem() data = self.tree_item_data.get(item) if not data: return self.current_path = data["path"] self.update_path_display() if not self.is_navigating_back: self.history.append(item) self.back_btn.Enable(len(self.history) > 1) self.list_ctrl.DeleteAllItems() self.current_items = [] self.set_status("Indlæser indhold...") threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start() 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": "" }) 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 }) wx.CallAfter(self._populate_list_ctrl, items_data, data) def _populate_list_ctrl(self, items_data, parent_data): 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 = "Mappe" if item['type'] == "FOLDER" else "Fil" if item['type'] == "FILE" else "Bibliotek" self.list_ctrl.SetItem(i, 1, type_str) self.list_ctrl.SetItem(i, 2, item['modified']) self.current_items.append(item) self.set_status("Klar") 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'] 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 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) # 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. Upload 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}") # 5. Checkin self.set_status(f"Tjekker '{file_name}' ind...") requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "Opdateret via SP Explorer"}) # 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()