Files
sharepoint-browser/sharepoint_browser.py

488 lines
20 KiB
Python

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.InitUI()
self.Centre()
self.Show()
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)
# 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)
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 LABEL
self.path_label = wx.StaticText(panel, label="📍 SharePoint")
self.path_label.SetFont(wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD))
vbox.Add(self.path_label, 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.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.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 clear_main(self):
self.list_ctrl.DeleteAllItems()
self.current_items = []
self.update_path_display()
def update_path_display(self):
path_str = " > ".join(self.current_path)
wx.CallAfter(self.path_label.SetLabel, f"📍 {path_str}")
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)
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.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)
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)
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):
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):
self.list_ctrl.InsertItem(i, item['name'])
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):
idx = event.GetIndex()
item = self.current_items[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 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):
base_url = f"https://graph.microsoft.com/v1.0/drives/{self.current_drive_id}/items/{item_id}"
try:
# 1. Checkout
self.set_status(f"Tjekker '{file_name}' ud...")
checkout_res = requests.post(f"{base_url}/checkout", headers=self.headers)
# 2. Download
self.set_status(f"Downloader '{file_name}'...")
res = requests.get(f"{base_url}/content", headers=self.headers)
if res.status_code != 200:
raise Exception(f"Download fejlede: {res.status_code}")
short_hash = hashlib.md5(item_id.encode()).hexdigest()[:8]
file_dir = os.path.join(TEMP_DIR, short_hash)
if not os.path.exists(file_dir):
os.makedirs(file_dir)
local_path = os.path.join(file_dir, file_name)
with open(local_path, 'wb') as f:
f.write(res.content)
# 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.")
wx.CallAfter(wx.MessageBox, f"Vi kunne ikke detektere om '{file_name}' blev låst.\n\nTryk OK når du har gemt og LUKKET filen.", "Info", wx.OK | wx.ICON_INFORMATION)
# 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"})
os.remove(local_path)
self.set_status(f"Succes! '{file_name}' er opdateret.")
wx.CallAfter(wx.MessageBox, f"Filen '{file_name}' er gemt og tjekket ind korrekt.", "Færdig", wx.OK | wx.ICON_INFORMATION)
except Exception as e:
self.set_status(f"Fejl: {str(e)}")
wx.CallAfter(wx.MessageBox, f"Der skete en fejl:\n{e}", "Fejl", wx.OK | wx.ICON_ERROR)
if __name__ == "__main__":
app = wx.App()
SharePointApp()
app.MainLoop()