Files
sharepoint-browser/sharepoint_browser.py

2084 lines
93 KiB
Python

import os
import time
import threading
import hashlib
import json
import sys
import requests
import msal
import wx
import wx.lib.newevent
import webbrowser
import re
import ctypes
from ctypes import wintypes
# --- STIHÅNDTERING (Til EXE-brug) ---
if getattr(sys, 'frozen', False):
# RESOURCE_DIR er mapper indeni EXE-filen (ikoner, billeder)
RESOURCE_DIR = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__)))
# CONFIG_DIR er mappen hvor selve .exe filen ligger (settings.json)
CONFIG_DIR = os.path.dirname(sys.executable)
else:
RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_DIR = RESOURCE_DIR
SETTINGS_FILE = os.path.join(CONFIG_DIR, 'settings.json')
def load_settings():
default_settings = {
"client_id": "DIN_CLIENT_ID_HER",
"tenant_id": "DIN_TENANT_ID_HER",
"temp_dir": "C:\\Temp_SP",
"language": "da", # da eller en
"favorites": [], # Liste over {id, name, type, drive_id, site_id, path}
"fav_visible": True,
"license_key": ""
}
if not os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(default_settings, f, indent=4)
return default_settings
with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
try:
return json.load(f)
except:
return default_settings
def save_settings(new_settings):
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(new_settings, f, indent=4)
settings = load_settings()
CLIENT_ID = settings.get("client_id")
TENANT_ID = settings.get("tenant_id")
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPES = ["Files.ReadWrite.All", "Sites.Read.All", "User.Read"]
TEMP_DIR = settings.get("temp_dir", "C:\\Temp_SP")
CURRENT_LANG = settings.get("language", "da")
# --- TRANSLATIONS ---
STRINGS = {
"da": {
"title": "SharePoint Explorer",
"btn_back": "Tilbage",
"btn_home": "Hjem",
"btn_save_changes": "Gem ændringer i SharePoint",
"btn_login": "Log ind",
"btn_logged_in": "Logget ind",
"btn_refresh": "Opdater",
"btn_upload_file": "Upload Fil",
"btn_upload_folder": "Upload Mappe",
"btn_new_folder": "Ny Mappe",
"col_name": "Navn",
"col_type": "Type",
"col_size": "Størrelse",
"col_modified": "Sidst ændret",
"type_folder": "Mappe",
"type_file": "Fil",
"type_drive": "Bibliotek",
"type_site": "Site",
"status_ready": "Klar",
"status_logging_in": "Logger ind...",
"status_fetching_sites": "Henter sites...",
"status_loading": "Indlæser...",
"status_loading_content": "Indlæser indhold...",
"status_fetching_drives": "Henter biblioteker...",
"msg_confirm_delete_single": "Er du sikker på, at du vil slette '{name}' permanent fra SharePoint?",
"msg_confirm_delete_multi": "Er du sikker på, at du vil slette {num} markerede emner permanent fra SharePoint?\n\n{names}",
"msg_delete_title": "Bekræft sletning",
"msg_error": "Fejl",
"msg_success": "Succes!",
"msg_edit_file": "Rediger fil",
"msg_delete": "Slet",
"msg_rename": "Omdøb",
"msg_rename_prompt": "Indtast det nye navn for '{name}':",
"msg_rename_title": "Omdøb emne",
"msg_open_browser": "Åbn i browser",
"msg_download": "Download",
"msg_downloading_to": "Downloader '{name}' til '{path}'...",
"msg_download_done": "'{name}' downloadet færdig.",
"msg_upload_here": "Upload fil her",
"msg_upload_folder_here": "Upload mappe her",
"msg_new_folder_here": "Opret ny mappe her",
"msg_uploading": "Uploader '{name}'...",
"msg_creating_folder": "Opretter mappen '{name}'...",
"msg_folder_done": "Mappe '{name}' færdig.",
"msg_new_folder_prompt": "Indtast navnet på den nye mappe:",
"msg_new_folder_title": "Ny Mappe",
"msg_drop_info": "Du kan kun uploade filer, når du er inde i et bibliotek eller en mappe.",
"msg_drop_title": "Vælg lokation",
"msg_select_file": "Vælg fil til upload",
"msg_select_folder": "Vælg mappe til upload",
"msg_edit_warning": "Du er i gang med at redigere en fil. Luk din editor og gem ændringerne før du lukker programmet.",
"msg_login_failed": "Login fejlede.",
"msg_upload_success": "'{name}' uploadet succesfuldt.",
"msg_upload_failed": "Upload af '{name}' fejlede med kode {code}",
"msg_delete_failed": "Kunne ikke slette '{name}'. Stopper...",
"msg_deleted_status": "Slettet {count} af {total} emner.",
"msg_fetching_file": "Henter '{name}'...",
"msg_opening_file": "Åbner '{name}'...",
"msg_waiting_for_file": "Venter på '{name}'...",
"msg_editing_file": "Redigerer '{name}' - Luk for at gemme.",
"msg_file_unchanged": "Ingen ændringer fundet. Springer upload over.",
"msg_updating_changes": "Uploader ændringer...",
"msg_checking_in": "Tjekker '{name}' ind...",
"msg_update_success": "Succes! '{name}' er opdateret.",
"msg_update_failed_code": "Upload fejlede: {code}",
"msg_unknown_error": "Ukendt fejl",
"type_unknown": "Ukendt",
"btn_settings": "Indstillinger",
"settings_title": "Indstillinger",
"settings_auth_group": "Authentication / API",
"settings_client_id": "App (Client) ID:",
"settings_tenant_id": "Tenant ID:",
"settings_path_group": "Systemstier",
"settings_temp_dir": "Midlertidig mappe:",
"settings_app_path": "Applikationssti:",
"settings_active_temp_path": "Aktuel Temp-sti:",
"settings_lang_group": "Sprog / UI",
"settings_language": "Programsprog:",
"settings_save": "Gem indstillinger",
"settings_cancel": "Annuller",
"msg_settings_saved": "Indstillingerne er gemt.",
"msg_restart_required": "Visse ændringer (f.eks. ID'er) træder først i kraft efter genstart.",
"status_login_needed": "Session udløbet. Log ind igen.",
"btn_add_fav": "Tilføj til favoritter",
"btn_remove_fav": "Fjern fra favoritter",
"label_favorites": "Favoritter",
"msg_fav_exists": "'{name}' er allerede i favoritter.",
"settings_license_group": "Licens / Aktivering",
"settings_license_key": "Licensnøgle:",
"settings_license_status": "Status: Ikke aktiveret"
},
"en": {
"title": "SharePoint Explorer",
"btn_back": "Back",
"btn_home": "Home",
"btn_save_changes": "Save changes to SharePoint",
"btn_login": "Login",
"btn_logged_in": "Logged in",
"btn_refresh": "Refresh",
"btn_upload_file": "Upload File",
"btn_upload_folder": "Upload Folder",
"btn_new_folder": "New Folder",
"col_name": "Name",
"col_type": "Type",
"col_size": "Size",
"col_modified": "Last Modified",
"type_folder": "Folder",
"type_file": "File",
"type_drive": "Library",
"type_site": "Site",
"status_ready": "Ready",
"status_logging_in": "Logging in...",
"status_fetching_sites": "Fetching sites...",
"status_loading": "Loading...",
"status_loading_content": "Loading content...",
"status_fetching_drives": "Fetching libraries...",
"msg_confirm_delete_single": "Are you sure you want to permanently delete '{name}' from SharePoint?",
"msg_confirm_delete_multi": "Are you sure you want to permanently delete {num} selected items from SharePoint?\n\n{names}",
"msg_delete_title": "Confirm Delete",
"msg_error": "Error",
"msg_success": "Success!",
"msg_edit_file": "Edit file",
"msg_delete": "Delete",
"msg_rename": "Rename",
"msg_rename_prompt": "Enter new name for '{name}':",
"msg_rename_title": "Rename item",
"msg_open_browser": "Open in browser",
"msg_download": "Download",
"msg_downloading_to": "Downloading '{name}' to '{path}'...",
"msg_download_done": "'{name}' download finished.",
"msg_upload_here": "Upload file here",
"msg_upload_folder_here": "Upload folder here",
"msg_new_folder_here": "Create new folder here",
"msg_uploading": "Uploading '{name}'...",
"msg_creating_folder": "Creating folder '{name}'...",
"msg_folder_done": "Folder '{name}' finished.",
"msg_new_folder_prompt": "Enter the name of the new folder:",
"msg_new_folder_title": "New Folder",
"msg_drop_info": "You can only upload files when you are inside a library or a folder.",
"msg_drop_title": "Select Location",
"msg_select_file": "Select file to upload",
"msg_select_folder": "Select folder to upload",
"msg_edit_warning": "An editing task is active. Please close your editor and save changes before closing the app.",
"msg_login_failed": "Login failed.",
"msg_upload_success": "'{name}' uploaded successfully.",
"msg_upload_failed": "Upload of '{name}' failed with status {code}",
"msg_delete_failed": "Could not delete '{name}'. Stopping...",
"msg_deleted_status": "Deleted {count} of {total} items.",
"msg_fetching_file": "Fetching '{name}'...",
"msg_opening_file": "Opening '{name}'...",
"msg_waiting_for_file": "Waiting for '{name}'...",
"msg_editing_file": "Editing '{name}' - Close window to save.",
"msg_file_unchanged": "No changes found. Skipping upload.",
"msg_updating_changes": "Uploading changes...",
"msg_checking_in": "Checking in '{name}'...",
"msg_update_success": "Success! '{name}' has been updated.",
"msg_update_failed_code": "Upload failed: {code}",
"msg_unknown_error": "Unknown error",
"type_unknown": "Unknown",
"btn_settings": "Settings",
"settings_title": "Settings",
"settings_auth_group": "Authentication / API",
"settings_client_id": "App (Client) ID:",
"settings_tenant_id": "Tenant ID:",
"settings_path_group": "System Paths",
"settings_temp_dir": "Temporary folder:",
"settings_app_path": "Application path:",
"settings_active_temp_path": "Active Temp path:",
"settings_lang_group": "Language / UI",
"settings_language": "App Language:",
"settings_save": "Save Settings",
"settings_cancel": "Cancel",
"msg_settings_saved": "Settings saved.",
"msg_restart_required": "Some changes (e.g., IDs) only take effect after restart.",
"status_login_needed": "Session expired. Please login again.",
"btn_add_fav": "Add to favorites",
"btn_remove_fav": "Remove from favorites",
"label_favorites": "Favorites",
"msg_fav_exists": "'{name}' is already in favorites.",
"settings_license_group": "License / Activation",
"settings_license_key": "License Key:",
"settings_license_status": "Status: Not activated"
}
}
if not os.path.exists(TEMP_DIR):
os.makedirs(TEMP_DIR)
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower()
for text in re.split('([0-9]+)', str(s))]
# --- NATIVE WINDOWS ICON HANDLING ---
if os.name == 'nt':
class SHFILEINFO(ctypes.Structure):
_fields_ = [
("hIcon", wintypes.HANDLE),
("iIcon", ctypes.c_int),
("dwAttributes", wintypes.DWORD),
("szDisplayName", wintypes.WCHAR * 260),
("szTypeName", wintypes.WCHAR * 80),
]
SHGFI_ICON = 0x000000100
SHGFI_SMALLICON = 0x000000001
SHGFI_USEFILEATTRIBUTES = 0x000000010
class UploadDropTarget(wx.FileDropTarget):
def __init__(self, window, app):
wx.FileDropTarget.__init__(self)
self.window = window
self.app = app
def OnDropFiles(self, x, y, filenames):
if not self.app.current_drive_id:
wx.MessageBox(self.app.get_txt("msg_drop_info"),
self.app.get_txt("msg_drop_title"), wx.OK | wx.ICON_INFORMATION)
return False
for path in filenames:
if os.path.isfile(path):
threading.Thread(target=self.app._upload_file_bg,
args=(path, self.app.current_drive_id, self.app.current_folder_id),
daemon=True).start()
elif os.path.isdir(path):
threading.Thread(target=self.app._upload_folder_bg,
args=(path, self.app.current_drive_id, self.app.current_folder_id),
daemon=True).start()
return True
def get_file_hash(path):
if not os.path.exists(path):
return None
sha256_hash = hashlib.sha256()
with open(path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def format_size(bytes_num):
if bytes_num is None:
return ""
if bytes_num < 1024:
return f"{bytes_num} B"
elif bytes_num < 1024**2:
return f"{bytes_num/1024:.1f} KB"
elif bytes_num < 1024**3:
return f"{bytes_num/(1024**2):.1f} MB"
else:
return f"{bytes_num/(1024**3):.1f} GB"
def is_configured(cfg):
placeholders = ["DIN_CLIENT_ID_HER", "DIN_TENANT_ID_HER", ""]
return cfg.get("client_id") not in placeholders and cfg.get("tenant_id") not in placeholders
class SettingsDialog(wx.Dialog):
def __init__(self, parent, current_settings):
lang = current_settings.get("language", "da")
title = STRINGS[lang].get("settings_title", "Settings")
super().__init__(parent, title=title, size=(520, 720))
self.settings = current_settings.copy()
self.lang = lang
self.InitUI()
def get_txt(self, key):
return STRINGS[self.lang].get(key, key)
def InitUI(self):
vbox = wx.BoxSizer(wx.VERTICAL)
panel = wx.Panel(self)
inner_vbox = wx.BoxSizer(wx.VERTICAL)
# --- Group: Authentication ---
auth_box = wx.StaticBox(panel, label=self.get_txt("settings_auth_group"))
auth_sizer = wx.StaticBoxSizer(auth_box, wx.VERTICAL)
grid = wx.FlexGridSizer(2, 2, 10, 10)
grid.AddGrowableCol(1, 1)
grid.Add(wx.StaticText(panel, label=self.get_txt("settings_client_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.client_id_ctrl = wx.TextCtrl(panel, value=self.settings.get("client_id", ""), size=(-1, 25))
grid.Add(self.client_id_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL)
grid.Add(wx.StaticText(panel, label=self.get_txt("settings_tenant_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.tenant_id_ctrl = wx.TextCtrl(panel, value=self.settings.get("tenant_id", ""), size=(-1, 25))
grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL)
auth_sizer.Add(grid, 1, wx.EXPAND | wx.ALL, 10)
inner_vbox.Add(auth_sizer, 0, wx.EXPAND | wx.ALL, 10)
# --- Group: Paths ---
path_box = wx.StaticBox(panel, label=self.get_txt("settings_path_group"))
path_sizer = wx.StaticBoxSizer(path_box, wx.VERTICAL)
path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_temp_dir")), 0, wx.BOTTOM, 5)
self.temp_dir_picker = wx.DirPickerCtrl(panel, path=self.settings.get("temp_dir", "C:\\Temp_SP"),
style=wx.DIRP_DIR_MUST_EXIST)
path_sizer.Add(self.temp_dir_picker, 0, wx.EXPAND | wx.BOTTOM, 10)
path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_app_path")), 0, wx.BOTTOM, 5)
app_path_box = wx.TextCtrl(panel, value=CONFIG_DIR, style=wx.TE_READONLY | wx.BORDER_NONE)
app_path_box.SetBackgroundColour(panel.GetBackgroundColour())
path_sizer.Add(app_path_box, 0, wx.EXPAND | wx.BOTTOM, 10)
path_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_active_temp_path")), 0, wx.BOTTOM, 5)
temp_path_box = wx.TextCtrl(panel, value=TEMP_DIR, style=wx.TE_READONLY | wx.BORDER_NONE)
temp_path_box.SetBackgroundColour(panel.GetBackgroundColour())
path_sizer.Add(temp_path_box, 0, wx.EXPAND)
inner_vbox.Add(path_sizer, 0, wx.EXPAND | wx.ALL, 10)
# --- Group: License ---
lic_box = wx.StaticBox(panel, label=self.get_txt("settings_license_group"))
lic_sizer = wx.StaticBoxSizer(lic_box, wx.VERTICAL)
lic_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_license_key")), 0, wx.BOTTOM, 5)
self.license_ctrl = wx.TextCtrl(panel, value=self.settings.get("license_key", ""))
lic_sizer.Add(self.license_ctrl, 0, wx.EXPAND | wx.BOTTOM, 5)
status_txt = wx.StaticText(panel, label=self.get_txt("settings_license_status"))
status_txt.SetForegroundColour(wx.RED)
lic_sizer.Add(status_txt, 0, wx.TOP, 5)
inner_vbox.Add(lic_sizer, 0, wx.EXPAND | wx.ALL, 10)
# --- Group: Language ---
lang_box = wx.StaticBox(panel, label=self.get_txt("settings_lang_group"))
lang_sizer = wx.StaticBoxSizer(lang_box, wx.HORIZONTAL)
lang_sizer.Add(wx.StaticText(panel, label=self.get_txt("settings_language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10)
self.lang_choice = wx.Choice(panel, choices=["Dansk", "English"])
self.lang_choice.SetSelection(0 if self.settings.get("language") == "da" else 1)
lang_sizer.Add(self.lang_choice, 1, wx.EXPAND)
inner_vbox.Add(lang_sizer, 0, wx.EXPAND | wx.ALL, 10)
panel.SetSizer(inner_vbox)
inner_vbox.Fit(panel)
vbox.Add(panel, 1, wx.EXPAND | wx.ALL, 0)
# --- Buttons ---
btn_hbox = wx.BoxSizer(wx.HORIZONTAL)
save_btn = wx.Button(self, label=self.get_txt("settings_save"), size=(150, 35))
save_btn.SetBackgroundColour(wx.Colour(0, 120, 215)) # SharePoint Blue
save_btn.SetForegroundColour(wx.WHITE)
save_btn.Bind(wx.EVT_BUTTON, self.on_save)
cancel_btn = wx.Button(self, label=self.get_txt("settings_cancel"), size=(100, 35))
cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel)
btn_hbox.Add(save_btn, 0, wx.RIGHT, 10)
btn_hbox.Add(cancel_btn, 0)
vbox.Add(btn_hbox, 0, wx.ALIGN_RIGHT | wx.ALL, 15)
self.SetSizer(vbox)
self.Layout()
self.Centre()
def on_save(self, event):
self.settings["client_id"] = self.client_id_ctrl.GetValue().strip()
self.settings["tenant_id"] = self.tenant_id_ctrl.GetValue().strip()
self.settings["temp_dir"] = self.temp_dir_picker.GetPath()
self.settings["language"] = "da" if self.lang_choice.GetSelection() == 0 else "en"
self.settings["license_key"] = self.license_ctrl.GetValue().strip()
if not self.settings["client_id"] or not self.settings["tenant_id"]:
wx.MessageBox("Client ID og Tenant ID skal udfyldes.", "Fejl", wx.OK | wx.ICON_ERROR)
return
self.EndModal(wx.ID_OK)
def on_cancel(self, event):
self.EndModal(wx.ID_CANCEL)
class StartGuideDialog(wx.Dialog):
def __init__(self, parent, current_settings):
lang = current_settings.get("language", "da")
title = "Velkommen til SharePoint Explorer" if lang == "da" else "Welcome to SharePoint Explorer"
super().__init__(parent, title=title, size=(600, 650))
self.settings = current_settings.copy()
self.lang = lang
self.InitUI()
self.Center()
def InitUI(self):
panel = wx.Panel(self)
vbox = wx.BoxSizer(wx.VERTICAL)
# Titel
title_lbl = wx.StaticText(panel, label="Opsætning af SharePoint Explorer" if self.lang == "da" else "SharePoint Explorer Setup")
title_font = title_lbl.GetFont()
title_font.MakeBold()
title_font.SetPointSize(14)
title_lbl.SetFont(title_font)
vbox.Add(title_lbl, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, 15)
# Guide tekst
guide_text_da = (
"For at programmet kan forbinde til din Microsoft 365, skal du oprette en App Registrering i Azure Portal.\n\n"
"Følg disse trin:\n"
"1. Gå til portal.azure.com og log ind med din administrator konto.\n"
"2. Søg efter 'App registrations' og klik på 'New registration'.\n"
"3. Giv den et navn (f.eks. 'SharePoint Explorer') og vælg 'Accounts in any organizational directory' (eller 'Accounts in this organizational directory only' hvis det kun er internt).\n"
"4. Vælg 'Public client/native (mobile & desktop)' under Redirect URI og skriv:\n http://localhost\n"
"5. Klik 'Register'.\n\n"
"Giv tilladelser (API permissions):\n"
"1. Klik på 'API permissions' i menuen til venstre.\n"
"2. Klik 'Add a permission' -> 'Microsoft Graph' -> 'Delegated permissions'.\n"
"3. Tilføj følgende: Files.ReadWrite.All, Sites.Read.All, og User.Read.\n"
"4. Husk at klikke 'Grant admin consent'.\n"
"5. Under 'Authentication' i menuen til venstre, sørg for at 'Allow public client flows' er aktiveret (slået til) i bunden af siden.\n\n"
"Kopier nu disse to værdier fra 'Overview' siden og sæt ind herunder:"
)
guide_ctrl = wx.TextCtrl(panel, value=guide_text_da, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE)
guide_ctrl.SetBackgroundColour(panel.GetBackgroundColour())
font = guide_ctrl.GetFont()
font.SetPointSize(10)
guide_ctrl.SetFont(font)
vbox.Add(guide_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 20)
# Input felter
grid = wx.FlexGridSizer(2, 2, 10, 10)
grid.AddGrowableCol(1, 1)
lbl_client = wx.StaticText(panel, label="App (Client) ID:")
self.client_id_ctrl = wx.TextCtrl(panel, value="")
grid.Add(lbl_client, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
grid.Add(self.client_id_ctrl, 1, wx.EXPAND)
lbl_tenant = wx.StaticText(panel, label="Directory (Tenant) ID:")
self.tenant_id_ctrl = wx.TextCtrl(panel, value="")
grid.Add(lbl_tenant, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND)
vbox.Add(grid, 0, wx.EXPAND | wx.ALL, 20)
# Gem/Annuller
btn_box = wx.BoxSizer(wx.HORIZONTAL)
save_btn = wx.Button(panel, label=self.get_txt("settings_save"), size=(150, 40))
save_btn.SetBackgroundColour(wx.Colour(0, 120, 215))
save_btn.SetForegroundColour(wx.WHITE)
save_btn.Bind(wx.EVT_BUTTON, self.on_save)
cancel_btn = wx.Button(panel, label=self.get_txt("settings_cancel"), size=(100, 40))
cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel)
btn_box.Add(save_btn, 0, wx.RIGHT, 10)
btn_box.Add(cancel_btn, 0)
vbox.Add(btn_box, 0, wx.ALIGN_RIGHT | wx.ALL, 15)
panel.SetSizer(vbox)
def on_save(self, event):
cid = self.client_id_ctrl.GetValue().strip()
tid = self.tenant_id_ctrl.GetValue().strip()
if not cid or not tid:
wx.MessageBox("Begge felter skal udfyldes for at fortsætte." if self.lang == "da" else "Both fields are required.", "Fejl", wx.OK | wx.ICON_ERROR)
return
self.settings["client_id"] = cid
self.settings["tenant_id"] = tid
save_settings(self.settings)
wx.MessageBox("Nye indstillinger gemt. Programmet genstarter for at anvende de nye ID'er." if self.lang == "da" else "Settings saved. App will restart.", "Succes", wx.OK | wx.ICON_INFORMATION)
self.EndModal(wx.ID_OK)
def on_cancel(self, event):
self.EndModal(wx.ID_CANCEL)
class SharePointApp(wx.Frame):
def __init__(self):
self.lang = CURRENT_LANG
super().__init__(None, title=self.get_txt("title"), size=(1000, 750))
# State
self.access_token = None
self.headers = {}
self.history = [] # Stack af (mode, id, path_segment)
self.current_path = ["SharePoint"]
self.current_site_id = None
self.current_drive_id = None
self.current_folder_id = "root"
self.current_items = [] # Gemmer graf-objekterne for rækkerne
self.tree_item_data = {} # Mappenoder -> {type, id, name, drive_id, path}
self.tree_root = None
self.is_navigating_back = False
self.active_edits = {} # item_id -> { "name": name, "event": Event, "waiting": bool }
self.favorites = settings.get("favorites", [])
self.fav_visible = settings.get("fav_visible", True)
self.sort_col = 0 # Default (Navn)
self.sort_asc = True
self.compact_mode = False
self.ext_icons = {} # Mapping fra .ext -> index i image_list
self.current_web_url = None # URL til nuværende lokation i browser
# System Ikoner
self.image_list = wx.ImageList(16, 16)
def add_icon(art_id, client=wx.ART_CMN_DIALOG):
bmp = wx.ArtProvider.GetBitmap(art_id, client, (16, 16))
if not bmp.IsOk():
bmp = wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, client, (16, 16)) # Fallback
return self.image_list.Add(bmp)
self.idx_site = add_icon(wx.ART_GO_HOME)
self.idx_drive = add_icon(wx.ART_HARDDISK)
self.idx_folder = add_icon(wx.ART_FOLDER)
self.idx_file = add_icon(wx.ART_NORMAL_FILE)
self.idx_star = add_icon(wx.ART_ADD_BOOKMARK)
self.idx_up = add_icon(wx.ART_GO_UP, wx.ART_TOOLBAR)
self.idx_down = add_icon(wx.ART_GO_DOWN, wx.ART_TOOLBAR)
# Threading/Sync til filredigering
# MSAL Cache
self.msal_app = None
if is_configured(settings):
try:
self.msal_app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
except Exception as e:
print(f"MSAL Init Error: {e}")
self.InitUI()
self.Centre()
self.Show()
self.Bind(wx.EVT_CLOSE, self.on_close_window)
# SÆT VINDUESIKON (øverste venstre hjørne)
icon_path = os.path.join(RESOURCE_DIR, "icon.ico")
if os.path.exists(icon_path):
self.SetIcon(wx.Icon(icon_path, wx.BITMAP_TYPE_ICO))
# Start indlæsning (Check for konfiguration)
if not is_configured(settings):
wx.CallAfter(self.show_start_guide)
def show_start_guide(self):
dlg = StartGuideDialog(self, settings)
if dlg.ShowModal() == wx.ID_OK:
import sys
os.execl(sys.executable, sys.executable, *sys.argv)
else:
self.Close()
dlg.Destroy()
def get_txt(self, key, **kwargs):
text = STRINGS[self.lang].get(key, key)
if kwargs:
try:
return text.format(**kwargs)
except:
pass
return text
def InitUI(self):
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
self.SetSizer(main_sizer)
main_sizer.Add(panel, 1, wx.EXPAND)
vbox = wx.BoxSizer(wx.VERTICAL)
# 0. INFO BAR (Beskeder der ikke blokerer)
self.info_bar = wx.InfoBar(panel)
vbox.Add(self.info_bar, 0, wx.EXPAND)
# Timer til at skjule info bar
self.info_timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, lambda e: self.info_bar.Dismiss(), self.info_timer)
# 1. TOP NAVIGATION BAR
nav_panel = wx.Panel(panel)
nav_hbox = wx.BoxSizer(wx.HORIZONTAL)
self.back_btn = wx.Button(nav_panel, label=self.get_txt("btn_back"), size=(110, 30))
self.back_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_BACK, wx.ART_BUTTON, (16, 16)))
self.back_btn.SetBitmapMargins((12, 0))
self.back_btn.Disable()
self.back_btn.Bind(wx.EVT_BUTTON, self.go_back)
nav_hbox.Add(self.back_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10)
self.home_btn = wx.Button(nav_panel, label=self.get_txt("btn_home"), size=(110, 30))
self.home_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_BUTTON, (16, 16)))
self.home_btn.SetBitmapMargins((12, 0))
self.home_btn.Disable()
self.home_btn.Bind(wx.EVT_BUTTON, self.load_sites)
nav_hbox.Add(self.home_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
self.refresh_btn = wx.Button(nav_panel, label=self.get_txt("btn_refresh"), size=(110, 30))
self.refresh_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_BUTTON, (16, 16)))
self.refresh_btn.SetBitmapMargins((12, 0))
self.refresh_btn.Disable()
self.refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh)
nav_hbox.Add(self.refresh_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
# NY KNAP: Gem ændringer (Vises kun ved redigering)
self.done_btn = wx.Button(nav_panel, label=self.get_txt("btn_save_changes"), size=(250, 30))
self.done_btn.SetBackgroundColour(wx.Colour(255, 69, 0)) # OrangeRed
self.done_btn.SetForegroundColour(wx.WHITE)
self.done_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_SAVE, wx.ART_BUTTON, (16, 16)))
self.done_btn.SetBitmapMargins((12, 0))
self.done_btn.Hide()
self.done_btn.Bind(wx.EVT_BUTTON, self.on_done_editing_clicked)
nav_hbox.Add(self.done_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10)
# NYE KNAPPER: Upload og Ny Mappe (Vises kun når man er inde i et drev/mappe)
self.upload_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_file"), size=(130, 30))
self.upload_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_BUTTON, (16, 16)))
self.upload_btn.SetBitmapMargins((12, 0))
self.upload_btn.Hide()
self.upload_btn.Bind(wx.EVT_BUTTON, self.on_upload_clicked)
nav_hbox.Add(self.upload_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
self.upload_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_folder"), size=(130, 30))
self.upload_folder_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FOLDER_OPEN, wx.ART_BUTTON, (16, 16)))
self.upload_folder_btn.SetBitmapMargins((12, 0))
self.upload_folder_btn.Hide()
self.upload_folder_btn.Bind(wx.EVT_BUTTON, self.on_upload_folder_clicked)
nav_hbox.Add(self.upload_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
self.new_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_new_folder"), size=(120, 30))
self.new_folder_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_NEW_DIR, wx.ART_BUTTON, (16, 16)))
self.new_folder_btn.SetBitmapMargins((12, 0))
self.new_folder_btn.Hide()
self.new_folder_btn.Bind(wx.EVT_BUTTON, self.on_new_folder_clicked)
nav_hbox.Add(self.new_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
nav_hbox.AddStretchSpacer(1)
self.login_btn = wx.Button(nav_panel, label=self.get_txt("btn_login"), size=(130, 30))
self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn
self.login_btn.SetForegroundColour(wx.WHITE)
self.login_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_TOOLBAR, (16, 16)))
self.login_btn.SetBitmapMargins((12, 0))
self.login_btn.Bind(wx.EVT_BUTTON, self.login)
nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
# INDSTILLINGER KNAP
self.settings_btn = wx.Button(nav_panel, label=self.get_txt("btn_settings"), size=(130, 30))
self.settings_btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REPORT_VIEW, wx.ART_TOOLBAR, (16, 16)))
self.settings_btn.SetBitmapMargins((12, 0))
self.settings_btn.Bind(wx.EVT_BUTTON, self.on_settings_clicked)
nav_hbox.Add(self.settings_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
nav_panel.SetSizer(nav_hbox)
vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5)
self.nav_hbox = nav_hbox # Gem til resize
self.nav_panel = nav_panel
# 2. PATH BREADCRUMBS (Adresselinje-stil)
self.path_panel = wx.Panel(panel, style=wx.BORDER_SIMPLE)
self.path_panel.SetBackgroundColour(wx.WHITE)
self.path_panel.SetMinSize((-1, 38)) # Sikrer synbarhed
self.path_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.path_panel.SetSizer(self.path_sizer)
vbox.Add(self.path_panel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
# 3. SPLITTER FOR TREE AND LIST
self.splitter = wx.SplitterWindow(panel, style=wx.SP_LIVE_UPDATE | wx.SP_3DSASH)
# Left side: Tree and Favorites Container
self.left_container = wx.Panel(self.splitter)
self.left_vbox = wx.BoxSizer(wx.VERTICAL)
self.left_container.SetSizer(self.left_vbox)
# LEFT SIDE - TOP: Tree
self.tree_ctrl = wx.TreeCtrl(self.left_container, style=wx.TR_DEFAULT_STYLE | wx.TR_HIDE_ROOT | wx.BORDER_SUNKEN)
self.tree_ctrl.AssignImageList(self.image_list)
self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_EXPANDING, self.on_tree_expanding)
self.tree_ctrl.Bind(wx.EVT_TREE_SEL_CHANGED, self.on_tree_selected)
self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_MENU, self.on_tree_right_click)
self.left_vbox.Add(self.tree_ctrl, 1, wx.EXPAND)
# LEFT SIDE - BOTTOM: Favorites
self.fav_section = wx.Panel(self.left_container)
self.fav_vbox = wx.BoxSizer(wx.VERTICAL)
self.fav_section.SetSizer(self.fav_vbox)
# Fav Header (Toggle)
self.fav_header = wx.Panel(self.fav_section)
self.fav_header.SetBackgroundColour(wx.Colour(240, 240, 240))
h_hbox = wx.BoxSizer(wx.HORIZONTAL)
# Star Icon
star_bmp = wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16))
self.fav_icon = wx.StaticBitmap(self.fav_header, bitmap=star_bmp)
h_hbox.Add(self.fav_icon, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 5)
self.fav_label = wx.StaticText(self.fav_header, label=self.get_txt("label_favorites"))
self.fav_label.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD))
h_hbox.Add(self.fav_label, 1, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 5)
self.fav_toggle_btn = wx.Button(self.fav_header, label="" if self.fav_visible else "", size=(25, 25), style=wx.BU_EXACTFIT)
self.fav_toggle_btn.Bind(wx.EVT_BUTTON, self.toggle_favorites)
h_hbox.Add(self.fav_toggle_btn, 0, wx.ALL, 2)
self.fav_header.SetSizer(h_hbox)
self.fav_vbox.Add(self.fav_header, 0, wx.EXPAND)
# Fav List
self.fav_list = wx.ListCtrl(self.fav_section, style=wx.LC_REPORT | wx.LC_NO_HEADER | wx.BORDER_SUNKEN)
self.fav_list.AssignImageList(self.image_list, wx.IMAGE_LIST_SMALL)
self.fav_list.InsertColumn(0, "Name", width=250)
self.fav_list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_favorite_activated)
self.fav_list.Bind(wx.EVT_CONTEXT_MENU, self.on_favorite_right_click)
self.fav_vbox.Add(self.fav_list, 1, wx.EXPAND)
self.left_vbox.Add(self.fav_section, 0, wx.EXPAND)
if not self.fav_visible:
self.fav_list.Hide()
else:
self.left_vbox.SetItemMinSize(self.fav_section, -1, 200)
# Right side: File Area - ListCtrl
self.list_ctrl = wx.ListCtrl(self.splitter, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
self.list_ctrl.AssignImageList(self.image_list, wx.IMAGE_LIST_SMALL)
self.list_ctrl.InsertColumn(0, self.get_txt("col_name"), width=450)
self.list_ctrl.InsertColumn(1, self.get_txt("col_type"), width=120)
self.list_ctrl.InsertColumn(2, self.get_txt("col_size"), width=80)
self.list_ctrl.InsertColumn(3, self.get_txt("col_modified"), width=180)
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
self.list_ctrl.Bind(wx.EVT_LIST_COL_CLICK, self.on_column_click)
self.list_ctrl.Bind(wx.EVT_CONTEXT_MENU, self.on_right_click)
# AKTIVER DRAG & DROP
self.list_ctrl.SetDropTarget(UploadDropTarget(self.list_ctrl, self))
self.splitter.SplitVertically(self.left_container, self.list_ctrl, 250)
self.splitter.SetMinimumPaneSize(100)
vbox.Add(self.splitter, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
# Load initial favorites
self.refresh_fav_list()
# 4. STATUS BAR
self.status_bar = self.CreateStatusBar()
self.status_bar.SetStatusText(self.get_txt("status_ready"))
panel.SetSizer(vbox)
self.Bind(wx.EVT_SIZE, self.on_resize)
self.Layout()
def on_right_click(self, event):
selected_indices = []
idx = self.list_ctrl.GetFirstSelected()
while idx != -1:
selected_indices.append(idx)
idx = self.list_ctrl.GetNextSelected(idx)
menu = wx.Menu()
if selected_indices:
# Menu for de valgte emner
selected_items = [self.current_items[i] for i in selected_indices]
if len(selected_indices) == 1:
item = selected_items[0]
if item['type'] in ["FOLDER", "DRIVE", "SITE"]:
fav_item = menu.Append(wx.ID_ANY, self.get_txt("btn_add_fav"))
fav_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e, i=item: self.add_favorite(i), fav_item)
menu.AppendSeparator()
if item['type'] == "FILE":
edit_item = menu.Append(wx.ID_ANY, self.get_txt("msg_edit_file"))
edit_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_EDIT, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e, i=item: self.open_file(i), edit_item)
if item['type'] in ["FILE", "FOLDER"]:
rename_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_rename')} '{item['name']}'")
rename_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REPORT_VIEW, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: self.on_rename_clicked(item), rename_item)
delete_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} '{item['name']}'")
delete_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DELETE, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_item)
# Åbn i browser
if item.get('web_url'):
menu.AppendSeparator()
web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser"))
web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e, url=item['web_url']: webbrowser.open(url), web_item)
else:
# Flere emner valgt
delete_items = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} {len(selected_indices)} " + ("emner" if self.lang == "da" else "items"))
delete_items.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DELETE, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_items)
else:
# Menu for selve mappen (hvis man trykker på det tomme felt)
if self.current_web_url:
web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser"))
web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(self.current_web_url), web_item)
menu.AppendSeparator()
if self.current_drive_id:
upload_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_here"))
upload_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, self.on_upload_clicked, upload_item)
upload_dir_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_folder_here"))
upload_dir_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_FOLDER_OPEN, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, self.on_upload_folder_clicked, upload_dir_item)
new_folder_item = menu.Append(wx.ID_ANY, self.get_txt("msg_new_folder_here"))
new_folder_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_NEW_DIR, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, self.on_new_folder_clicked, new_folder_item)
# Tilføj altid opdater punkt til sidst
if menu.GetMenuItemCount() > 0:
menu.AppendSeparator()
refresh_item = menu.Append(wx.ID_ANY, self.get_txt("btn_refresh"))
refresh_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, self.on_refresh, refresh_item)
self.PopupMenu(menu)
menu.Destroy()
def on_tree_right_click(self, event):
item = event.GetItem()
if not item.IsOk() or item == self.tree_root: return
self.tree_ctrl.SelectItem(item)
data = self.tree_item_data.get(item)
menu = wx.Menu()
if data:
fav_item = menu.Append(wx.ID_ANY, self.get_txt("btn_add_fav"))
fav_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e, d=data: self.add_favorite(d), fav_item)
if data.get('web_url'):
menu.AppendSeparator()
web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser"))
web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e, url=data['web_url']: webbrowser.open(url), web_item)
menu.AppendSeparator()
refresh_item = menu.Append(wx.ID_ANY, self.get_txt("btn_refresh"))
refresh_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, self.on_refresh, refresh_item)
self.PopupMenu(menu)
menu.Destroy()
# --- FAVORITES LOGIC ---
def add_favorite(self, item):
# Check if exists
for fav in self.favorites:
if fav['id'] == item['id']:
self.show_info(self.get_txt("msg_fav_exists", name=item['name']), wx.ICON_INFORMATION)
return
new_fav = {
"id": item['id'],
"name": item['name'],
"type": item['type'],
"drive_id": item.get('drive_id'),
"site_id": item.get('id') if item['type'] == "SITE" else self.current_site_id,
"path": self.current_path + [item['name']],
"web_url": item.get('web_url')
}
self.favorites.append(new_fav)
self.save_favorites()
self.refresh_fav_list()
self.show_info(self.get_txt("msg_success"))
def remove_favorite(self, id):
self.favorites = [f for f in self.favorites if f['id'] != id]
self.save_favorites()
self.refresh_fav_list()
def save_favorites(self):
settings["favorites"] = self.favorites
save_settings(settings)
def refresh_fav_list(self):
self.fav_list.DeleteAllItems()
for i, fav in enumerate(self.favorites):
img_idx = self.idx_star
if fav['type'] == "DRIVE": img_idx = self.idx_drive
elif fav['type'] == "SITE": img_idx = self.idx_site
elif fav['type'] == "FOLDER": img_idx = self.idx_folder
self.fav_list.InsertItem(i, fav['name'], img_idx)
self.fav_list.SetItemData(i, i) # Store index
def on_favorite_activated(self, event):
idx = event.GetIndex()
if idx < 0 or idx >= len(self.favorites): return
fav = self.favorites[idx]
self.current_path = fav['path']
self.update_path_display()
# Navigate to contents
data = {
"type": fav['type'],
"id": fav['id'],
"drive_id": fav['drive_id'],
"path": fav['path']
}
if fav['type'] == "SITE": self.current_site_id = fav['id']
elif fav['drive_id']: self.current_drive_id = fav['drive_id']
self.list_ctrl.DeleteAllItems()
self.current_items = []
self.set_status(self.get_txt("status_loading_content"))
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
def on_favorite_right_click(self, event):
idx = self.fav_list.GetFirstSelected()
if idx < 0: return
fav = self.favorites[idx]
menu = wx.Menu()
if fav.get('web_url'):
web_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser"))
web_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(fav['web_url']), web_item)
menu.AppendSeparator()
remove_item = menu.Append(wx.ID_ANY, self.get_txt("btn_remove_fav"))
remove_item.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_DEL_BOOKMARK, wx.ART_MENU, (16, 16)))
self.Bind(wx.EVT_MENU, lambda e: self.remove_favorite(fav['id']), remove_item)
self.PopupMenu(menu)
menu.Destroy()
def toggle_favorites(self, event=None):
self.fav_visible = not self.fav_visible
self.fav_toggle_btn.SetLabel("" if self.fav_visible else "")
if self.fav_visible:
self.fav_list.Show()
self.left_vbox.SetItemMinSize(self.fav_section, -1, 200)
else:
self.fav_list.Hide()
self.left_vbox.SetItemMinSize(self.fav_section, -1, 30)
settings["fav_visible"] = self.fav_visible
save_settings(settings)
self.left_container.Layout()
# --- FILHÅNDTERING (Upload, Slet, Ny Mappe) ---
def on_delete_items_clicked(self, items):
if not items: return
names = ", ".join([f"'{i['name']}'" for i in items[:3]])
if len(items) > 3:
names += f" og {len(items)-3} andre..." if self.lang == "da" else f" and {len(items)-3} others..."
msg = self.get_txt("msg_confirm_delete_multi", num=len(items), names=names)
res = wx.MessageBox(msg, self.get_txt("msg_delete_title"), wx.YES_NO | wx.ICON_WARNING)
if res == wx.YES:
threading.Thread(target=self._delete_multiple_bg, args=(items,), daemon=True).start()
def _delete_multiple_bg(self, items):
if not self.ensure_valid_token(): return
self.lock_ui(True)
count = 0
total = len(items)
for item in items:
status_text = "Sletter" if self.lang == "da" else "Deleting"
self.set_status(f"{status_text} {count+1}/{total}: '{item['name']}'...")
url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}"
res = requests.delete(url, headers=self.headers)
if res.status_code in [204, 200]:
count += 1
else:
self.set_status(self.get_txt("msg_delete_failed", name=item['name']))
wx.CallAfter(wx.MessageBox, f"Error deleting '{item['name']}': {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
break
self._refresh_current_view()
self.lock_ui(False)
self.set_status(self.get_txt("msg_deleted_status", count=count, total=total))
def on_upload_clicked(self, event):
if not self.current_drive_id: return
with wx.FileDialog(self, self.get_txt("msg_select_file"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fd:
if fd.ShowModal() == wx.ID_OK:
path = fd.GetPath()
threading.Thread(target=self._upload_file_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start()
def _upload_file_bg(self, local_path, drive_id, parent_id):
if not self.ensure_valid_token(): return
filename = os.path.basename(local_path)
self.set_status(self.get_txt("msg_uploading", name=filename))
# Simpel upload (virker op til 4MB)
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content"
try:
with open(local_path, 'rb') as f:
res = requests.put(url, headers=self.headers, data=f)
if res.status_code in [200, 201]:
self.set_status(self.get_txt("msg_upload_success", name=filename))
self._refresh_current_view()
else:
self.set_status(self.get_txt("msg_upload_failed", name=filename, code=res.status_code))
wx.CallAfter(wx.MessageBox, self.get_txt("msg_upload_failed", name=filename, code=res.status_code), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
except Exception as e:
self.set_status(f"Upload error: {e}")
def on_upload_folder_clicked(self, event):
if not self.current_drive_id: return
with wx.DirDialog(self, self.get_txt("msg_select_folder"), style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) as dd:
if dd.ShowModal() == wx.ID_OK:
path = dd.GetPath()
threading.Thread(target=self._upload_folder_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start()
def _upload_folder_bg(self, local_dir, drive_id, parent_id):
if not self.ensure_valid_token(): return
dirname = os.path.basename(local_dir)
self.set_status(self.get_txt("msg_creating_folder", name=dirname))
# 1. Opret mappen på SharePoint
folder_id = self._create_folder_sync(dirname, drive_id, parent_id)
if not folder_id:
return
# 2. Upload filer i mappen
for item in os.listdir(local_dir):
full_path = os.path.join(local_dir, item)
if os.path.isfile(full_path):
self._upload_file_bg_sync(full_path, drive_id, folder_id)
elif os.path.isdir(full_path):
self._upload_folder_bg(full_path, drive_id, folder_id) # Rekursivt
self.set_status(self.get_txt("msg_folder_done", name=dirname))
self._refresh_current_view()
def _create_folder_sync(self, name, drive_id, parent_id):
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}/children"
body = {"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "rename"}
res = requests.post(url, headers=self.headers, json=body)
if res.status_code in [200, 201]:
return res.json().get('id')
return None
def _upload_file_bg_sync(self, local_path, drive_id, parent_id):
# Hjælper til sync upload brugt af mappe-upload
filename = os.path.basename(local_path)
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content"
with open(local_path, 'rb') as f:
requests.put(url, headers=self.headers, data=f)
def on_new_folder_clicked(self, event):
if not self.current_drive_id: return
dlg = wx.TextEntryDialog(self, self.get_txt("msg_new_folder_prompt"), self.get_txt("msg_new_folder_title"))
if dlg.ShowModal() == wx.ID_OK:
name = dlg.GetValue()
if name:
threading.Thread(target=self._create_folder_bg, args=(name, self.current_drive_id, self.current_folder_id), daemon=True).start()
dlg.Destroy()
def _create_folder_bg(self, name, drive_id, parent_id):
if not self.ensure_valid_token(): return
self.set_status(self.get_txt("msg_creating_folder", name=name))
folder_id = self._create_folder_sync(name, drive_id, parent_id)
if folder_id:
self.set_status(self.get_txt("msg_success"))
self._refresh_current_view()
else:
self.set_status(self.get_txt("msg_error"))
def on_rename_clicked(self, item):
dlg = wx.TextEntryDialog(self, self.get_txt("msg_rename_prompt", name=item['name']), self.get_txt("msg_rename_title"), item['name'])
if dlg.ShowModal() == wx.ID_OK:
new_name = dlg.GetValue()
if new_name and new_name != item['name']:
threading.Thread(target=self._rename_item_bg, args=(item, new_name), daemon=True).start()
dlg.Destroy()
def _rename_item_bg(self, item, new_name):
if not self.ensure_valid_token(): return
self.set_status(f"{self.get_txt('msg_rename')}...")
url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}"
body = {"name": new_name}
res = requests.patch(url, headers=self.headers, json=body)
if res.status_code in [200, 201]:
self.set_status(self.get_txt("msg_success"))
self._refresh_current_view()
else:
self.set_status(self.get_txt("msg_error"))
wx.CallAfter(wx.MessageBox, f"Rename failed: {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
def on_download_clicked(self, item):
if not self.ensure_valid_token(): return
if item['type'] == "FILE":
with wx.FileDialog(self, self.get_txt("msg_select_file"), defaultFile=item['name'], style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as fd:
if fd.ShowModal() == wx.ID_OK:
path = fd.GetPath()
threading.Thread(target=self._download_file_bg_task, args=(item, path), daemon=True).start()
else:
# Mappe eller Drev
with wx.DirDialog(self, self.get_txt("msg_select_folder"), style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) as dd:
if dd.ShowModal() == wx.ID_OK:
parent_path = dd.GetPath()
dest_path = os.path.join(parent_path, item['name'])
threading.Thread(target=self._download_folder_bg_task, args=(item, dest_path), daemon=True).start()
def _download_file_bg_task(self, item, dest_path):
if not self.ensure_valid_token(): return
self.set_status(self.get_txt("msg_downloading_to", name=item['name'], path=dest_path))
if self._download_file_sync_call(item['drive_id'], item['id'], dest_path):
self.set_status(self.get_txt("msg_download_done", name=item['name']))
else:
self.set_status(self.get_txt("msg_error"))
def _download_file_sync_call(self, drive_id, item_id, dest_path):
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{item_id}/content"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
with open(dest_path, 'wb') as f:
f.write(res.content)
return True
return False
def _download_folder_bg_task(self, item, dest_path):
if not self.ensure_valid_token(): return
self.set_status(self.get_txt("msg_downloading_to", name=item['name'], path=dest_path))
self._download_folder_recursive_sync(item['drive_id'], item['id'], dest_path)
self.set_status(self.get_txt("msg_download_done", name=item['name']))
def _download_folder_recursive_sync(self, drive_id, folder_id, local_dir):
if not os.path.exists(local_dir):
os.makedirs(local_dir)
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
items = res.json().get('value', [])
for item in items:
item_path = os.path.join(local_dir, item['name'])
if 'folder' in item:
self._download_folder_recursive_sync(drive_id, item['id'], item_path)
else:
self._download_file_sync_call(drive_id, item['id'], item_path)
def set_status(self, text):
wx.CallAfter(self.status_bar.SetStatusText, text)
def show_info(self, text, type=wx.ICON_INFORMATION, auto_hide=True):
def _do():
self.info_bar.Dismiss()
self.info_bar.ShowMessage(text, type)
if auto_hide:
self.info_timer.Start(4000, oneShot=True)
else:
self.info_timer.Stop()
wx.CallAfter(_do)
def on_done_editing_clicked(self, event):
waiting_files = [fid for fid, d in self.active_edits.items() if d.get("waiting")]
if not waiting_files:
return
if len(waiting_files) == 1:
fid = waiting_files[0]
self.active_edits[fid]["event"].set()
else:
# Show menu to let user pick which file is finished
menu = wx.Menu()
for fid in waiting_files:
name = self.active_edits[fid]["name"]
item = menu.Append(wx.ID_ANY, f"Gem '{name}'")
# closure to capture fid
def make_handler(f_id):
return lambda e: self.active_edits[f_id]["event"].set()
self.Bind(wx.EVT_MENU, make_handler(fid), item)
menu.AppendSeparator()
item_all = menu.Append(wx.ID_ANY, "Gem alle")
def handle_all(e):
for f in waiting_files:
if f in self.active_edits:
self.active_edits[f]["event"].set()
self.Bind(wx.EVT_MENU, handle_all, item_all)
self.PopupMenu(menu)
menu.Destroy()
def on_settings_clicked(self, event):
dlg = SettingsDialog(self, settings)
if dlg.ShowModal() == wx.ID_OK:
global CLIENT_ID, TENANT_ID, AUTHORITY, TEMP_DIR, CURRENT_LANG
new_settings = dlg.settings
# Check if IDs changed (need refresh)
ids_changed = (new_settings["client_id"] != settings["client_id"] or
new_settings["tenant_id"] != settings["tenant_id"])
# Save
save_settings(new_settings)
# Update global variables for current session
settings.update(new_settings)
CLIENT_ID = settings["client_id"]
TENANT_ID = settings["tenant_id"]
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
TEMP_DIR = settings["temp_dir"]
# Apply language
if CURRENT_LANG != new_settings["language"]:
CURRENT_LANG = new_settings["language"]
self.lang = CURRENT_LANG
self.refresh_ui_texts()
# Update MSAL App if IDs changed
if ids_changed:
self.msal_app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
self.access_token = None
self.headers = {}
self.login_btn.Enable()
self.login_btn.SetLabel(self.get_txt("btn_login"))
self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 100))
self.show_info(self.get_txt("msg_restart_required"), wx.ICON_INFORMATION)
# Ensure temp dir exists
if not os.path.exists(TEMP_DIR):
os.makedirs(TEMP_DIR)
self.show_info(self.get_txt("msg_settings_saved"))
dlg.Destroy()
def refresh_ui_texts(self):
# Update UI texts for main frame and buttons
self.SetTitle(self.get_txt("title"))
# Hvis vi er i compact mode, skal vi ikke sætte labels på knapperne nu
if not self.compact_mode:
self._update_button_labels(full=True)
self.settings_btn.SetLabel(self.get_txt("btn_settings") if not self.compact_mode else "")
if self.access_token:
self.login_btn.SetLabel(self.get_txt("btn_logged_in") if not self.compact_mode else "")
else:
self.login_btn.SetLabel(self.get_txt("btn_login") if not self.compact_mode else "")
# Re-set headers for ListCtrl
cols = [self.get_txt("col_name"), self.get_txt("col_type"), self.get_txt("col_size"), self.get_txt("col_modified")]
for i, text in enumerate(cols):
info = self.list_ctrl.GetColumn(i)
info.SetText(text)
info.SetImage(-1)
self.list_ctrl.SetColumn(i, info)
self.set_status(self.get_txt("status_ready"))
self._refresh_current_view()
def on_language_changed(self, event):
# Deprecated: use on_settings_clicked instead if you want or keep for quick switch
pass # We'll just remove or redirect it later
def on_close_window(self, event):
if self.active_edits:
self.show_info(self.get_txt("msg_edit_warning"), wx.ICON_WARNING, auto_hide=False)
return
event.Skip()
def lock_ui(self, lock=True):
def _do():
self.tree_ctrl.Enable(not lock)
self.list_ctrl.Enable(not lock)
self.back_btn.Enable(not lock if len(self.history) > 1 else False)
self.home_btn.Enable(not lock)
self.refresh_btn.Enable(not lock)
self.login_btn.Enable(not lock)
wx.CallAfter(_do)
def on_refresh(self, event=None):
selected = self.tree_ctrl.GetSelection()
if not selected.IsOk() or selected == self.tree_root:
self.load_sites()
return
data = self.tree_item_data.get(selected)
if data:
self.set_status(f"Opdaterer '{data['name']}'...")
# Opdater Listekontrol (højre side)
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
# Opdater Træstruktur (venstre side) - Ryd og indlæs igen hvis det er en container
if data['type'] in ["SITE", "DRIVE", "FOLDER"]:
data["loaded"] = False # Tillad genindlæsning
if self.tree_ctrl.IsExpanded(selected):
# Genindlæs træets børn asynkront
threading.Thread(target=self._fetch_tree_children_bg, args=(selected, data), daemon=True).start()
def _refresh_current_view(self):
sel = self.tree_ctrl.GetSelection()
if sel.IsOk():
data = self.tree_item_data.get(sel)
if data:
# Kør i nuværende baggrundstråd hvis muligt, ellers ny
self._fetch_list_contents_bg(data)
def clear_main(self):
self.list_ctrl.DeleteAllItems()
self.current_items = []
self.update_path_display()
def on_resize(self, event):
width = self.GetSize().width
threshold = 1100
if width < threshold and not self.compact_mode:
self.compact_mode = True
self._update_button_labels(full=False)
elif width >= threshold and self.compact_mode:
self.compact_mode = False
self._update_button_labels(full=True)
event.Skip()
def _update_button_labels(self, full=True):
if not self: return
try:
# Liste over knapper og deres tilhørende oversættelses-nøgle
btns = [
(self.back_btn, "btn_back", 40, 110),
(self.home_btn, "btn_home", 40, 110),
(self.refresh_btn, "btn_refresh", 40, 110),
(self.upload_btn, "btn_upload_file", 40, 130),
(self.upload_folder_btn, "btn_upload_folder", 40, 130),
(self.new_folder_btn, "btn_new_folder", 40, 120),
(self.settings_btn, "btn_settings", 40, 130)
]
for btn, key, compact_w, full_w in btns:
txt = self.get_txt(key)
btn.SetLabel(txt if full else "")
btn.SetToolTip(txt)
btn.SetMinSize((compact_w if not full else full_w, 30))
btn.SetSize((compact_w if not full else full_w, 30))
btn.SetBitmapMargins((12 if full else 10, 0))
# Special cases for Login and Done buttons
if full:
login_txt = self.get_txt("btn_logged_in") if self.access_token else self.get_txt("btn_login")
self.login_btn.SetLabel(login_txt)
self.login_btn.SetToolTip(login_txt)
self.login_btn.SetMinSize((130, 30))
self.login_btn.SetBitmapMargins((12, 0))
done_txt = self.get_txt("btn_save_changes")
self.done_btn.SetLabel(done_txt)
self.done_btn.SetToolTip(done_txt)
self.done_btn.SetMinSize((250, 30))
self.done_btn.SetBitmapMargins((12, 0))
else:
login_txt = self.get_txt("btn_logged_in") if self.access_token else self.get_txt("btn_login")
self.login_btn.SetLabel("")
self.login_btn.SetToolTip(login_txt)
self.login_btn.SetMinSize((40, 30))
self.login_btn.SetBitmapMargins((10, 0))
done_txt = self.get_txt("btn_save_changes")
self.done_btn.SetLabel("")
self.done_btn.SetToolTip(done_txt)
self.done_btn.SetMinSize((40, 30))
self.done_btn.SetBitmapMargins((10, 0))
self.nav_panel.Layout()
self.Layout()
except RuntimeError:
pass
def update_path_display(self):
if not self: return
try:
self.path_sizer.Clear(True)
self._add_path_segment(self.get_txt("title"), "ROOT")
# Vis stien fra self.current_path
path_segments = self.current_path[1:] if self.current_path and self.current_path[0] == "SharePoint" else self.current_path
# Prøv at finde matchende noder i træet for at gøre brødkrummerne klikbare
curr_node = self.tree_root
for name in path_segments:
arrow = wx.StaticText(self.path_panel, label=" > ")
arrow.SetForegroundColour(wx.Colour(150, 150, 150))
self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL)
found_node = None
if curr_node:
child, cookie = self.tree_ctrl.GetFirstChild(curr_node)
while child.IsOk():
if self.tree_ctrl.GetItemText(child) == name:
found_node = child
break
child, cookie = self.tree_ctrl.GetNextChild(curr_node, cookie)
self._add_path_segment(name, found_node)
curr_node = found_node # Fortsæt ned i træet hvis muligt
self.path_panel.Layout()
self.path_panel.Refresh()
self.Layout()
except RuntimeError:
pass
def _add_path_segment(self, label, node):
btn = wx.Button(self.path_panel, label=label, style=wx.BU_EXACTFIT | wx.BORDER_NONE)
btn.SetBackgroundColour(wx.WHITE)
btn.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL))
if node == "ROOT":
btn.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_CMN_DIALOG, (16, 16)))
btn.SetBitmapMargins((4, 0))
btn.Bind(wx.EVT_BUTTON, self.load_sites)
elif node:
btn.Bind(wx.EVT_BUTTON, lambda e: self.tree_ctrl.SelectItem(node))
self.path_sizer.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
def ensure_valid_token(self):
"""Sikrer at vi har et gyldigt token. Returnerer True hvis OK."""
try:
accounts = self.msal_app.get_accounts()
if not accounts:
self.set_status(self.get_txt("status_login_needed"))
return False
result = self.msal_app.acquire_token_silent(SCOPES, account=accounts[0])
if result and "access_token" in result:
self.access_token = result["access_token"]
self.headers = {'Authorization': f'Bearer {self.access_token}'}
return True
except Exception as e:
print(f"Token refresh error: {e}")
self.set_status(self.get_txt("status_login_needed"))
return False
def login(self, event):
self.set_status(self.get_txt("status_logging_in"))
accounts = self.msal_app.get_accounts()
result = None
if accounts:
result = self.msal_app.acquire_token_silent(SCOPES, account=accounts[0])
if not result or "access_token" not in result:
result = self.msal_app.acquire_token_interactive(scopes=SCOPES)
if "access_token" in result:
self.access_token = result["access_token"]
self.headers = {'Authorization': f'Bearer {self.access_token}'}
self.login_btn.Disable()
# self.login_btn.Hide() # Valgfrit: Skjul login knap helt når vi er inde
self.login_btn.SetLabel(self.get_txt("btn_logged_in"))
self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå
self.home_btn.Enable()
self.refresh_btn.Enable()
self.load_sites()
else:
self.set_status(self.get_txt("status_login_failed"))
wx.CallAfter(wx.MessageBox, result.get("error_description", self.get_txt("msg_unknown_error")), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
def load_sites(self, event=None):
self.set_status(self.get_txt("status_fetching_sites"))
self.tree_ctrl.DeleteAllItems()
self.list_ctrl.DeleteAllItems()
self.current_items = []
self.tree_item_data = {}
self._pending_tree_selection_id = None
self.tree_root = self.tree_ctrl.AddRoot("HiddenRoot")
self.current_path = ["SharePoint"]
self.history = []
self.update_path_display()
threading.Thread(target=self._fetch_sites_bg, daemon=True).start()
def _fetch_sites_bg(self):
if not self.ensure_valid_token(): return
url = "https://graph.microsoft.com/v1.0/sites?search=*"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
sites = res.json().get('value', [])
sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower())
wx.CallAfter(self._populate_sites_tree, sites)
else:
self.set_status(self.get_txt("msg_unknown_error"))
def _populate_sites_tree(self, sites):
self.set_status(f"{len(sites)} sites.")
for site in sites:
name = site.get('displayName', site.get('name'))
node = self.tree_ctrl.AppendItem(self.tree_root, name, image=self.idx_site)
self.tree_item_data[node] = {
"type": "SITE", "id": site['id'], "name": name,
"drive_id": None, "path": ["SharePoint", name], "loaded": False,
"web_url": site.get('webUrl')
}
self.tree_ctrl.SetItemHasChildren(node, True)
# Select the first site or just show in list (defaulting to showing root sites in list)
self.list_ctrl.DeleteAllItems()
self.current_items = []
for i, site in enumerate(sites):
name = site.get('displayName', site.get('name'))
self.list_ctrl.InsertItem(i, name, self.idx_site)
self.list_ctrl.SetItem(i, 1, self.get_txt("type_site"))
self.list_ctrl.SetItem(i, 2, "") # Størrelse
self.list_ctrl.SetItem(i, 3, "") # Sidst ændret
self.current_items.append({
"type": "SITE", "id": site['id'], "name": name,
"size": None, "modified": "", "web_url": site.get('webUrl')
})
def on_tree_expanding(self, event):
item = event.GetItem()
data = self.tree_item_data.get(item)
if not data or data.get("loaded"):
return
loading_node = self.tree_ctrl.AppendItem(item, self.get_txt("status_loading"))
threading.Thread(target=self._fetch_tree_children_bg, args=(item, data), daemon=True).start()
def _fetch_tree_children_bg(self, parent_node, data):
if not self.ensure_valid_token(): return
if data['type'] == "SITE":
url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
drives = res.json().get('value', [])
drives.sort(key=lambda x: x.get('name', '').lower())
wx.CallAfter(self._populate_tree_drives, parent_node, drives, data)
elif data['type'] == "DRIVE":
url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
items = res.json().get('value', [])
folders = [x for x in items if 'folder' in x]
folders.sort(key=lambda x: x['name'].lower())
wx.CallAfter(self._populate_tree_folders, parent_node, folders, data)
elif data['type'] == "FOLDER":
url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
items = res.json().get('value', [])
folders = [x for x in items if 'folder' in x]
folders.sort(key=lambda x: x['name'].lower())
wx.CallAfter(self._populate_tree_folders, parent_node, folders, data)
def _populate_tree_drives(self, parent_node, drives, parent_data):
self.tree_ctrl.DeleteChildren(parent_node)
parent_data["loaded"] = True
target_node = None
for drive in drives:
name = drive.get('name', self.get_txt("type_unknown"))
drive_id = drive['id']
node = self.tree_ctrl.AppendItem(parent_node, name, image=self.idx_drive)
self.tree_item_data[node] = {
"type": "DRIVE", "id": drive_id, "name": name,
"drive_id": drive_id, "path": parent_data["path"] + [name], "loaded": False,
"web_url": drive.get('webUrl')
}
self.tree_ctrl.SetItemHasChildren(node, True)
if drive_id == getattr(self, '_pending_tree_selection_id', None):
target_node = node
if target_node:
self._pending_tree_selection_id = None
self.tree_ctrl.SelectItem(target_node)
def _populate_tree_folders(self, parent_node, folders, parent_data):
self.tree_ctrl.DeleteChildren(parent_node)
parent_data["loaded"] = True
target_node = None
for folder in folders:
name = folder['name']
folder_id = folder['id']
node = self.tree_ctrl.AppendItem(parent_node, name, image=self.idx_folder)
self.tree_item_data[node] = {
"type": "FOLDER", "id": folder_id, "name": name,
"drive_id": parent_data["drive_id"], "path": parent_data["path"] + [name], "loaded": False,
"web_url": folder.get('webUrl')
}
self.tree_ctrl.SetItemHasChildren(node, True)
if folder_id == getattr(self, '_pending_tree_selection_id', None):
target_node = node
if target_node:
self._pending_tree_selection_id = None
self.tree_ctrl.SelectItem(target_node)
def on_tree_selected(self, event):
if not self: return
item = event.GetItem()
data = self.tree_item_data.get(item)
if not data:
return
try:
self.current_path = data["path"]
self.update_path_display()
if not self.is_navigating_back:
self.history.append(item)
# Check if button still exists
if self.back_btn:
self.back_btn.Enable(len(self.history) > 1)
self.list_ctrl.DeleteAllItems()
self.current_items = []
self.set_status(self.get_txt("status_loading_content"))
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
except RuntimeError:
pass
def _fetch_list_contents_bg(self, data):
if not self.ensure_valid_token(): return
items_data = []
if data['type'] == "SITE":
url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
drives = res.json().get('value', [])
drives.sort(key=lambda x: x.get('name', '').lower())
for drive in drives:
items_data.append({
"type": "DRIVE", "id": drive['id'], "name": drive.get('name', ''),
"drive_id": drive['id'], "modified": "", "size": None,
"web_url": drive.get('webUrl')
})
elif data['type'] in ["DRIVE", "FOLDER"]:
drive_id = data['drive_id']
if data['type'] == "DRIVE":
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children"
else:
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{data['id']}/children"
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
items = res.json().get('value', [])
items.sort(key=lambda x: (not 'folder' in x, x['name'].lower()))
for item in items:
is_folder = 'folder' in item
modified = item.get('lastModifiedDateTime', '').replace('T', ' ').split('.')[0]
items_data.append({
"type": "FOLDER" if is_folder else "FILE",
"id": item['id'], "name": item['name'],
"drive_id": drive_id, "modified": modified,
"size": item.get('size') if not is_folder else None,
"web_url": item.get('webUrl')
})
wx.CallAfter(self._populate_list_ctrl, items_data, data)
def on_column_click(self, event):
col = event.GetColumn()
if col == self.sort_col:
self.sort_asc = not self.sort_asc
else:
self.sort_col = col
self.sort_asc = True
self.apply_sorting()
def apply_sorting(self):
if not self.current_items: return
# Priority: SITE < DRIVE < FOLDER < FILE
type_prio = {"SITE": 0, "DRIVE": 1, "FOLDER": 2, "FILE": 3}
def sort_logic(item):
# Altid grupper efter type først (mapper øverst)
p = type_prio.get(item['type'], 9)
val = ""
if self.sort_col == 0: # Name
val = natural_sort_key(item['name'])
elif self.sort_col == 1: # Type
val = item['type']
elif self.sort_col == 2: # Size
val = item['size'] if item['size'] is not None else -1
elif self.sort_col == 3: # Modified
val = item['modified']
return (p, val)
self.current_items.sort(key=sort_logic, reverse=not self.sort_asc)
self._update_list_view_only()
def get_icon_idx_for_file(self, filename):
ext = os.path.splitext(filename)[1].lower()
if not ext or ext == ".":
return self.idx_file
if ext in self.ext_icons:
return self.ext_icons[ext]
# Prøv native Windows Shell API (SHGetFileInfo)
if os.name == 'nt':
try:
# Sæt argtypes så vi er sikre på typerne
shell32 = ctypes.windll.shell32
shell32.SHGetFileInfoW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(SHFILEINFO), wintypes.UINT, wintypes.UINT]
shell32.SHGetFileInfoW.restype = wintypes.DWORD
sfi = SHFILEINFO()
# Brug et dummy-filnavn fremfor blot endelsen (sikrer bedre match på tværs af Windows versioner)
dummy_file = "C:\\file" + ext
# Eksplicit unicode buffer for at undgå konverteringsfejl
path_buf = ctypes.create_unicode_buffer(dummy_file)
res = shell32.SHGetFileInfoW(path_buf, 0x80, ctypes.byref(sfi), ctypes.sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON | SHGFI_USEFILEATTRIBUTES)
if res and sfi.hIcon:
# Mest kompatible måde at få en bitmap fra HICON i wxPython
bmp = wx.Bitmap.FromHICON(sfi.hIcon)
if bmp.IsOk():
# Sørg for at den er 16x16
if bmp.GetWidth() != 16 or bmp.GetHeight() != 16:
img = bmp.ConvertToImage()
bmp = wx.Bitmap(img.Scale(16, 16, wx.IMAGE_QUALITY_HIGH))
idx = self.image_list.Add(bmp)
# Ryd op i handle med det samme
ctypes.windll.user32.DestroyIcon(sfi.hIcon)
self.ext_icons[ext] = idx
return idx
ctypes.windll.user32.DestroyIcon(sfi.hIcon)
except Exception as e:
pass
# Gammelt fallback til MimeTypesManager (hvis SHGetFileInfo fejler)
try:
with wx.LogNull():
ft = wx.TheMimeTypesManager.GetFileTypeFromExtension(ext[1:] if ext.startswith('.') else ext)
if ft:
info = ft.GetIconInfo()
if info:
icon = info[0] if isinstance(info, tuple) else info.GetIcon()
if icon and icon.IsOk():
bmp = wx.Bitmap(icon)
if bmp.GetWidth() != 16 or bmp.GetHeight() != 16:
img = bmp.ConvertToImage()
bmp = wx.Bitmap(img.Scale(16, 16, wx.IMAGE_QUALITY_HIGH))
idx = self.image_list.Add(bmp)
self.ext_icons[ext] = idx
return idx
except:
pass
self.ext_icons[ext] = self.idx_file
return self.idx_file
# Fallback
self.ext_icons[ext_clean] = self.idx_file
return self.idx_file
def _update_list_view_only(self):
self.list_ctrl.DeleteAllItems()
for i, item in enumerate(self.current_items):
img_idx = self.idx_file
if item['type'] == "FOLDER": img_idx = self.idx_folder
elif item['type'] == "DRIVE": img_idx = self.idx_drive
elif item['type'] == "SITE": img_idx = self.idx_site
elif item['type'] == "FILE":
img_idx = self.get_icon_idx_for_file(item['name'])
self.list_ctrl.InsertItem(i, item['name'], img_idx)
type_str = self.get_txt("type_folder") if item['type'] == "FOLDER" else self.get_txt("type_file") if item['type'] == "FILE" else self.get_txt("type_drive")
self.list_ctrl.SetItem(i, 1, type_str)
size_str = format_size(item['size']) if item['size'] is not None else ""
self.list_ctrl.SetItem(i, 2, size_str)
self.list_ctrl.SetItem(i, 3, item['modified'])
# Opdater kolonne ikoner
for col in range(4):
info = self.list_ctrl.GetColumn(col)
if col == self.sort_col:
info.SetImage(self.idx_up if self.sort_asc else self.idx_down)
else:
info.SetImage(-1)
self.list_ctrl.SetColumn(col, info)
def _populate_list_ctrl(self, items_data, parent_data):
if not self: return
try:
self.current_items = items_data
# Anvend sortering før visning
self.apply_sorting()
# Opdater tilstand
self.current_web_url = parent_data.get('web_url')
if parent_data['type'] == "SITE":
self.current_site_id = parent_data['id']
elif parent_data['type'] == "DRIVE":
self.current_drive_id = parent_data['id']
self.current_folder_id = "root"
elif parent_data['type'] == "FOLDER":
self.current_drive_id = parent_data['drive_id']
self.current_folder_id = parent_data['id']
# Opdater knap-synlighed
can_upload = self.current_drive_id is not None
wx.CallAfter(lambda: self._safe_update_buttons(can_upload))
except RuntimeError:
pass
def _safe_update_buttons(self, can_upload):
try:
if not self: return
self.upload_btn.Show(can_upload)
self.upload_folder_btn.Show(can_upload)
self.new_folder_btn.Show(can_upload)
self.Layout()
except RuntimeError:
pass
def on_item_activated(self, event):
item_idx = event.GetIndex()
item = self.current_items[item_idx]
if item['type'] in ["SITE", "DRIVE", "FOLDER"]:
self._sync_tree_selection(item['id'])
elif item['type'] == "FILE":
self.open_file(item)
def _sync_tree_selection(self, target_id):
selected = self.tree_ctrl.GetSelection()
if not selected.IsOk():
selected = self.tree_root
if selected.IsOk():
data = self.tree_item_data.get(selected)
if data and not data.get("loaded"):
self._pending_tree_selection_id = target_id
self.tree_ctrl.Expand(selected)
return
child, cookie = self.tree_ctrl.GetFirstChild(selected)
while child.IsOk():
cdata = self.tree_item_data.get(child)
if cdata and cdata['id'] == target_id:
self.tree_ctrl.SelectItem(child)
return
child, cookie = self.tree_ctrl.GetNextChild(selected, cookie)
def go_back(self, event=None):
if len(self.history) > 1:
self.history.pop() # Remove current
prev_item = self.history[-1] # Peak at previous
self.is_navigating_back = True
self.tree_ctrl.SelectItem(prev_item)
self.is_navigating_back = False
def open_file(self, item):
item_id = item['id']
file_name = item['name']
drive_id = item['drive_id']
if item_id in self.active_edits:
self.show_info(f"'{file_name}' er allerede ved at blive redigeret.", wx.ICON_INFORMATION)
return
if len(self.active_edits) >= 10:
wx.MessageBox("Du kan kun have 10 filer åbne til redigering ad gangen.", "Maksimum grænse nået", wx.OK | wx.ICON_WARNING)
return
threading.Thread(target=self.process_file, args=(item_id, file_name, drive_id), daemon=True).start()
def update_edit_ui(self):
def _do():
try:
if not self: return
count = len(self.active_edits)
waiting_count = sum(1 for d in self.active_edits.values() if d.get("waiting"))
if waiting_count > 0:
self.done_btn.SetLabel(f"{self.get_txt('btn_save_changes')} ({waiting_count})")
self.done_btn.Show()
else:
self.done_btn.Hide()
# Opdater statusbesked hvis der er aktive opgaver
if count > 0:
status_msg = f"Aktive filer: {count}"
if waiting_count > 0:
status_msg += f" ({waiting_count} venter på gem)"
self.set_status(status_msg)
else:
self.set_status(self.get_txt("status_ready"))
self.Layout()
except RuntimeError:
pass
wx.CallAfter(_do)
def process_file(self, item_id, file_name, drive_id):
if not self.ensure_valid_token(): return
edit_event = threading.Event()
self.active_edits[item_id] = {"name": file_name, "event": edit_event, "waiting": False}
self.update_edit_ui()
try:
# 1. Lokation info
base_url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{item_id}"
# Unik undermappe baseret på ID, men brug originalt filnavn indeni
item_hash = hashlib.md5(item_id.encode()).hexdigest()[:8]
working_dir = os.path.join(TEMP_DIR, item_hash)
if not os.path.exists(working_dir):
os.makedirs(working_dir)
local_path = os.path.join(working_dir, file_name)
# 2. Download
self.set_status(self.get_txt("msg_fetching_file", name=file_name))
res = requests.get(f"{base_url}/content", headers=self.headers)
if res.status_code != 200:
raise Exception(f"{self.get_txt('msg_unknown_error')}: {res.status_code}")
with open(local_path, 'wb') as f:
f.write(res.content)
# Beregn udgangspunkt hash
original_hash = get_file_hash(local_path)
# Checkout
requests.post(f"{base_url}/checkout", headers=self.headers)
# 3. Åbn & Overvåg
self.set_status(self.get_txt("msg_opening_file", name=file_name))
os.startfile(local_path)
locked = False
self.set_status(self.get_txt("msg_waiting_for_file", name=file_name))
for _ in range(10):
time.sleep(1)
try:
os.rename(local_path, local_path)
except OSError:
locked = True
break
if locked:
self.set_status(self.get_txt("msg_editing_file", name=file_name))
while True:
time.sleep(2)
try:
os.rename(local_path, local_path)
break
except OSError:
pass
else:
self.set_status(self.get_txt("msg_waiting_for_file", name=file_name))
edit_event.clear()
self.active_edits[item_id]["waiting"] = True
self.update_edit_ui()
edit_event.wait()
if item_id in self.active_edits:
self.active_edits[item_id]["waiting"] = False
self.update_edit_ui()
# 4. Tjek om noget er ændret
new_hash = get_file_hash(local_path)
if original_hash == new_hash:
self.set_status(self.get_txt("msg_file_unchanged"))
else:
# 5. Upload (kun hvis ændret)
self.set_status(self.get_txt("msg_updating_changes"))
with open(local_path, 'rb') as f:
upload_res = requests.put(f"{base_url}/content", headers=self.headers, data=f)
if upload_res.status_code not in [200, 201]:
raise Exception(f"{self.get_txt('msg_update_failed_code', code=upload_res.status_code)}")
# 6. Checkin (Uanset om ændret eller ej, for at frigive lås)
self.set_status(self.get_txt("msg_checking_in", name=file_name))
requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"})
# Oprydning: Slet fil og derefter mappe
try:
os.remove(local_path)
os.rmdir(working_dir)
except:
pass
self.set_status(self.get_txt("msg_update_success", name=file_name))
self.show_info(self.get_txt("msg_update_success", name=file_name), wx.ICON_INFORMATION)
self._refresh_current_view()
except Exception as e:
self.set_status(f"{self.get_txt('msg_error')}: {str(e)}")
self.show_info(f"{self.get_txt('msg_error')}: {e}", wx.ICON_ERROR)
finally:
if item_id in self.active_edits:
del self.active_edits[item_id]
self.update_edit_ui()
if __name__ == "__main__":
app = wx.App()
SharePointApp()
app.MainLoop()