Files
sharepoint-browser/sharepoint_browser.py

2432 lines
108 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
import base64
import logging
from ctypes import wintypes
# --- LOGGING & KONSTANTER ---
def setup_logging(enabled=True):
level = logging.INFO if enabled else logging.CRITICAL
# Fjern eksisterende handlers hvis vi kalder den igen
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(level=level, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SP_Browser")
# Initial setup (INFO) - vil blive opdateret efter settings er indlæst
setup_logging(True)
CHUNK_SIZE = 1 * 1024 * 1024 # 1MB
ENABLE_HASH_VALIDATION = True
HASH_THRESHOLD_MB = 250
try:
import quickxorhash as qxh_lib
except ImportError:
qxh_lib = None
# --- 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,
"logging_enabled": 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")
# Opdater logging baseret på gemte indstillinger
setup_logging(settings.get("logging_enabled", True))
# --- 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",
"settings_logging_group": "System / Diverse",
"settings_logging": "Aktiver log-output (anbefales til fejlfinding)",
"settings_about_group": "Om programmet",
"settings_credits": "© 2026 SharePoint Explorer\n\nSkabt af:\nMartin Tranberg\nBlueprint\n\nBernhard Bangs Allé 23, 2.\n2000 Frederiksberg\n\nTel: 70258689"
},
"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",
"settings_logging_group": "System / Miscellaneous",
"settings_logging": "Enable log output (recommended for troubleshooting)",
"settings_about_group": "About",
"settings_credits": "© 2026 SharePoint Explorer\n\nCreated by:\nMartin Tranberg\nBlueprint\n\nBernhard Bangs Allé 23, 2.\nDK-2000 Frederiksberg\n\nPhone: +45 70258689"
}
}
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
CHUNK_SIZE = 1 * 1024 * 1024 # 1MB chunks
ENABLE_HASH_VALIDATION = True
HASH_THRESHOLD_MB = 250 # Grænse for hvornår vi gider tjekke hash (pga. hastighed i Python)
# --- HJÆLPEFUNKTIONER ---
def get_long_path(path):
if os.name == 'nt' and not path.startswith('\\\\?\\'):
return '\\\\?\\' + os.path.abspath(path)
return path
def quickxorhash(file_path):
"""Compute Microsoft QuickXorHash for a file. Returns base64-encoded string."""
lp = get_long_path(file_path)
if not os.path.exists(lp): return None
if qxh_lib:
hasher = qxh_lib.quickxorhash()
with open(lp, 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk: break
hasher.update(chunk)
return base64.b64encode(hasher.digest()).decode('ascii')
# Fallback til manuel Python implementering
h = 0
length = 0
mask = (1 << 160) - 1
with open(lp, 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk: break
for b in chunk:
shift = (length * 11) % 160
shifted = b << shift
wrapped = (shifted & mask) | (shifted >> 160)
h ^= wrapped
length += 1
h ^= (length << (160 - 64))
result = h.to_bytes(20, byteorder='little')
return base64.b64encode(result).decode('ascii')
def verify_integrity(local_path, remote_hash):
"""Verifies file integrity based on global settings."""
if not remote_hash or not ENABLE_HASH_VALIDATION:
return True
lp = get_long_path(local_path)
if not os.path.exists(lp): return False
file_size = os.path.getsize(lp)
threshold_bytes = HASH_THRESHOLD_MB * 1024 * 1024
if file_size > threshold_bytes:
logger.info(f"Skipping hash check (size > {HASH_THRESHOLD_MB}MB): {os.path.basename(local_path)}")
return True
local_hash = quickxorhash(local_path)
if local_hash != remote_hash:
logger.warning(f"Hash mismatch for {local_path}: local={local_hash}, remote={remote_hash}")
return False
return True
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=(580, 550))
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)
# --- TABBED INTERFACE (wx.Notebook) ---
self.nb = wx.Notebook(self)
# 1. ACCOUNT TAB
account_panel = wx.Panel(self.nb)
account_vbox = wx.BoxSizer(wx.VERTICAL)
grid = wx.FlexGridSizer(2, 2, 10, 10)
grid.AddGrowableCol(1, 1)
grid.Add(wx.StaticText(account_panel, label=self.get_txt("settings_client_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.client_id_ctrl = wx.TextCtrl(account_panel, value=self.settings.get("client_id", ""), size=(-1, 25))
grid.Add(self.client_id_ctrl, 1, wx.EXPAND)
grid.Add(wx.StaticText(account_panel, label=self.get_txt("settings_tenant_id")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.tenant_id_ctrl = wx.TextCtrl(account_panel, value=self.settings.get("tenant_id", ""), size=(-1, 25))
grid.Add(self.tenant_id_ctrl, 1, wx.EXPAND)
account_vbox.Add(grid, 1, wx.EXPAND | wx.ALL, 15)
account_panel.SetSizer(account_vbox)
self.nb.AddPage(account_panel, self.get_txt("settings_auth_group").split("/")[0].strip())
# 2. PATHS TAB
paths_panel = wx.Panel(self.nb)
paths_vbox = wx.BoxSizer(wx.VERTICAL)
paths_inner = wx.BoxSizer(wx.VERTICAL)
paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_temp_dir")), 0, wx.BOTTOM, 5)
self.temp_dir_picker = wx.DirPickerCtrl(paths_panel, path=self.settings.get("temp_dir", "C:\\Temp_SP"), style=wx.DIRP_DIR_MUST_EXIST)
paths_inner.Add(self.temp_dir_picker, 0, wx.EXPAND | wx.BOTTOM, 15)
paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_app_path")), 0, wx.BOTTOM, 5)
app_path_box = wx.TextCtrl(paths_panel, value=CONFIG_DIR, style=wx.TE_READONLY | wx.BORDER_NONE)
app_path_box.SetBackgroundColour(paths_panel.GetBackgroundColour())
paths_inner.Add(app_path_box, 0, wx.EXPAND | wx.BOTTOM, 15)
paths_inner.Add(wx.StaticText(paths_panel, label=self.get_txt("settings_active_temp_path")), 0, wx.BOTTOM, 5)
temp_path_box = wx.TextCtrl(paths_panel, value=TEMP_DIR, style=wx.TE_READONLY | wx.BORDER_NONE)
temp_path_box.SetBackgroundColour(paths_panel.GetBackgroundColour())
paths_inner.Add(temp_path_box, 0, wx.EXPAND)
paths_vbox.Add(paths_inner, 1, wx.EXPAND | wx.ALL, 15)
paths_panel.SetSizer(paths_vbox)
self.nb.AddPage(paths_panel, self.get_txt("settings_path_group"))
# 3. LICENSE TAB
lic_panel = wx.Panel(self.nb)
lic_vbox = wx.BoxSizer(wx.VERTICAL)
lic_inner = wx.BoxSizer(wx.VERTICAL)
lic_inner.Add(wx.StaticText(lic_panel, label=self.get_txt("settings_license_key")), 0, wx.BOTTOM, 5)
self.license_ctrl = wx.TextCtrl(lic_panel, value=self.settings.get("license_key", ""))
lic_inner.Add(self.license_ctrl, 0, wx.EXPAND | wx.BOTTOM, 5)
status_txt = wx.StaticText(lic_panel, label=self.get_txt("settings_license_status"))
status_txt.SetForegroundColour(wx.RED)
lic_inner.Add(status_txt, 0, wx.TOP, 5)
lic_vbox.Add(lic_inner, 1, wx.EXPAND | wx.ALL, 15)
lic_panel.SetSizer(lic_vbox)
self.nb.AddPage(lic_panel, self.get_txt("settings_license_group").split("/")[0].strip())
# 4. SYSTEM TAB
sys_panel = wx.Panel(self.nb)
sys_vbox = wx.BoxSizer(wx.VERTICAL)
sys_inner = wx.BoxSizer(wx.VERTICAL)
lang_hbox = wx.BoxSizer(wx.HORIZONTAL)
lang_hbox.Add(wx.StaticText(sys_panel, label=self.get_txt("settings_language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10)
self.lang_choice = wx.Choice(sys_panel, choices=["Dansk", "English"])
self.lang_choice.SetSelection(0 if self.settings.get("language") == "da" else 1)
lang_hbox.Add(self.lang_choice, 1, wx.EXPAND)
sys_inner.Add(lang_hbox, 0, wx.EXPAND | wx.BOTTOM, 15)
self.logging_cb = wx.CheckBox(sys_panel, label=self.get_txt("settings_logging"))
self.logging_cb.SetValue(self.settings.get("logging_enabled", True))
sys_inner.Add(self.logging_cb, 0, wx.ALL, 5)
sys_vbox.Add(sys_inner, 1, wx.EXPAND | wx.ALL, 15)
sys_panel.SetSizer(sys_vbox)
self.nb.AddPage(sys_panel, "System")
# 5. ABOUT TAB
about_panel = wx.Panel(self.nb)
about_vbox = wx.BoxSizer(wx.VERTICAL)
about_info = wx.TextCtrl(about_panel, value=self.get_txt("settings_credits"), style=wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE)
about_info.SetBackgroundColour(about_panel.GetBackgroundColour())
about_vbox.Add(about_info, 1, wx.EXPAND | wx.ALL, 15)
about_panel.SetSizer(about_vbox)
self.nb.AddPage(about_panel, self.get_txt("settings_about_group"))
vbox.Add(self.nb, 1, wx.EXPAND | wx.ALL, 10)
# --- 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()
self.settings["logging_enabled"] = self.logging_cb.GetValue()
# Anvend logning med det samme
setup_logging(self.settings["logging_enabled"])
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 get_txt(self, key):
return STRINGS[self.lang].get(key, key)
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.current_path_data = [] # Gemmer data-objekterne for den nuværende sti (brødkrummer)
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(2)
self.status_bar.SetStatusWidths([-1, 150]) # Text field and Gauge field
self.status_bar.SetStatusText(self.get_txt("status_ready"), 0)
# Add a Gauge to the status bar
self.gauge = wx.Gauge(self.status_bar, range=100, size=(140, 18), style=wx.GA_HORIZONTAL | wx.GA_SMOOTH)
self.gauge.Hide()
self.Bind(wx.EVT_SIZE, self.on_status_bar_resize)
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]
added_fav = False
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)
added_fav = True
added_file_action = False
if item['type'] == "FILE":
if added_fav: menu.AppendSeparator()
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)
added_file_action = True
added_folder_action = False
if item['type'] in ["FILE", "FOLDER"]:
if added_fav and not added_file_action: menu.AppendSeparator()
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)
added_folder_action = True
# Åbn i browser
if item.get('web_url'):
if added_fav or added_file_action or added_folder_action:
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]
# Opret navigations-data fra favoritten
data = {
"type": fav['type'],
"id": fav['id'],
"name": fav['name'],
"drive_id": fav['drive_id'],
"path": fav['path'],
"web_url": fav.get('web_url')
}
# Vis indholdet og opdater stien
self._navigate_to_item_data(data)
# Forsøg at synkronisere træet asynkront
wx.CallAfter(self._sync_tree_selection_by_path, fav['path'])
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):
remote_hash = item.get('hash')
if remote_hash and not verify_integrity(dest_path, remote_hash):
self.show_info(f"Advarsel: Hash mismatch ved download af '{item['name']}'", wx.ICON_WARNING)
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"
while url:
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
res_data = res.json()
items = res_data.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)
url = res_data.get('@odata.nextLink')
else:
break
def set_status(self, text, field=0):
wx.CallAfter(self.status_bar.SetStatusText, text, field)
def pulse_gauge(self, start=True):
def _do():
if not self: return
if start:
self.gauge.Show()
self.gauge.Pulse()
else:
self.gauge.Hide()
self.gauge.SetValue(0)
self.Layout()
wx.CallAfter(_do)
def show_info(self, text, type=wx.ICON_INFORMATION, auto_hide=True):
def _do():
if not self: return
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):
# 1. Prøv at bruge den aktuelle navigationskontekst (pålideligt ved Favorites/Breadcrumbs)
if hasattr(self, 'current_path_data') and self.current_path_data:
data = self.current_path_data[-1]
self.set_status(f"Opdaterer '{data['name']}'...")
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
return
# 2. Fallback til Træ-kontrol markering
sel = self.tree_ctrl.GetSelection()
if sel.IsOk():
data = self.tree_item_data.get(sel)
if data:
self.set_status(f"Opdaterer '{data['name']}'...")
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
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 on_status_bar_resize(self, event):
if hasattr(self, 'gauge') and self.gauge:
rect = self.status_bar.GetFieldRect(1)
self.gauge.SetPosition((rect.x + 5, rect.y + 2))
self.gauge.SetSize((rect.width - 10, rect.height - 4))
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_data
for data in self.current_path_data:
arrow = wx.StaticText(self.path_panel, label=" > ")
arrow.SetForegroundColour(wx.Colour(150, 150, 150))
self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL)
self._add_path_segment(data['name'], data)
self.path_panel.Layout()
self.path_panel.Refresh()
self.Layout()
except RuntimeError:
pass
def _add_path_segment(self, label, data):
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 data == "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 data:
def on_click(e, d=data):
self._navigate_to_item_data(d, is_breadcrumb=True)
# Efter navigation, prøv at finde og vælge den i træet
wx.CallAfter(self._sync_tree_selection_by_path, d["path"])
btn.Bind(wx.EVT_BUTTON, on_click)
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.SetLabel(self.get_txt("btn_logged_in") if not getattr(self, "compact_mode", False) else "")
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.current_path_data = []
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
self.pulse_gauge(True)
all_sites = []
url = "https://graph.microsoft.com/v1.0/sites?search=*"
while url:
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
data = res.json()
all_sites.extend(data.get('value', []))
url = data.get('@odata.nextLink')
self.set_status(f"{self.get_txt('status_fetching_sites')} ({len(all_sites)}...)")
self.pulse_gauge(True)
else:
break
if all_sites:
all_sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower())
wx.CallAfter(self._populate_sites_tree, all_sites)
else:
self.set_status(self.get_txt("msg_unknown_error"))
self.pulse_gauge(False)
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)
# Root sites in list view
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, "")
self.list_ctrl.SetItem(i, 3, "")
self.current_items.append({
"type": "SITE", "id": site['id'], "name": name,
"size": None, "modified": "", "web_url": site.get('webUrl'),
"path": ["SharePoint", 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, 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
self.pulse_gauge(True)
all_children = []
if data['type'] == "SITE":
url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives"
elif data['type'] == "DRIVE":
url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children"
elif data['type'] == "FOLDER":
url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children"
while url:
res = requests.get(url, headers=self.headers)
if res.status_code == 200:
res_data = res.json()
all_children.extend(res_data.get('value', []))
url = res_data.get('@odata.nextLink')
self.pulse_gauge(True)
else:
break
if data['type'] == "SITE":
all_children.sort(key=lambda x: x.get('name', '').lower())
wx.CallAfter(self._populate_tree_drives, parent_node, all_children, data)
else:
folders = [x for x in all_children if 'folder' in x]
folders.sort(key=lambda x: x['name'].lower())
wx.CallAfter(self._populate_tree_folders, parent_node, folders, data)
self.pulse_gauge(False)
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
self._navigate_to_item_data(data, tree_item=item)
def _navigate_to_item_data(self, data, tree_item=None, is_breadcrumb=False):
try:
# Race-condition beskyttelse: Hvis vi allerede er der, så stop (undtagen ved brødkrumme-klik)
if not is_breadcrumb and getattr(self, 'current_path', None) == data.get("path"):
return
self.current_path = data["path"]
# Opdater brødkrumme-data (vi gemmer segmenter EFTER SharePoint)
new_len = len(self.current_path)
if new_len > 1:
# Trunker listen så den passer til den nye dybde (behold forældre)
# Hvis stien er [S, A, D], skal data-stien være [A, D] (længde 2).
# Før append skal vi have 1 element tilbage (indeks 0).
self.current_path_data = self.current_path_data[:new_len-2]
self.current_path_data.append(data)
else:
self.current_path_data = []
self.update_path_display()
if tree_item and not self.is_navigating_back:
# Undgå dubletter i historikken hvis vi allerede er der
if not self.history or self.history[-1] != tree_item:
self.history.append(tree_item)
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
self.pulse_gauge(True)
items_data = []
if data['type'] == "SITE":
url = f"https://graph.microsoft.com/v1.0/sites/{data['id']}/drives"
elif data['type'] == "DRIVE":
url = f"https://graph.microsoft.com/v1.0/drives/{data['id']}/root/children"
else: # FOLDER
url = f"https://graph.microsoft.com/v1.0/drives/{data['drive_id']}/items/{data['id']}/children"
first_chunk = True
while url:
res = requests.get(url, headers=self.headers)
if res.status_code != 200: break
res_data = res.json()
raw_items = res_data.get('value', [])
# Map items
chunk_data = []
drive_id = data.get('drive_id') if data['type'] != "SITE" else None
for item in raw_items:
if data['type'] == "SITE":
name = item.get('name', '')
chunk_data.append({
"type": "DRIVE", "id": item['id'], "name": name,
"drive_id": item['id'], "modified": "", "size": None,
"web_url": item.get('webUrl'),
"path": data['path'] + [name]
})
else:
is_folder = 'folder' in item
modified = item.get('lastModifiedDateTime', '').replace('T', ' ').split('.')[0]
chunk_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'),
"hash": item.get('file', {}).get('hashes', {}).get('quickXorHash') if not is_folder else None,
"path": data['path'] + [item['name']]
})
items_data.extend(chunk_data)
self.set_status(f"Henter... ({len(items_data)} emner)")
self.pulse_gauge(True)
# Chunked UI Update
if first_chunk:
wx.CallAfter(self._populate_list_ctrl, chunk_data, data, finalize=False)
first_chunk = False
else:
wx.CallAfter(self._append_list_items, chunk_data)
url = res_data.get('@odata.nextLink')
# Finalize
wx.CallAfter(self._finalize_list_loading, items_data)
self.pulse_gauge(False)
def _append_list_items(self, items):
if not self: return
start_idx = len(self.current_items)
self.current_items.extend(items)
for i, item in enumerate(items):
idx = start_idx + i
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(idx, 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(idx, 1, type_str)
size_str = format_size(item['size']) if item['size'] is not None else ""
self.list_ctrl.SetItem(idx, 2, size_str)
self.list_ctrl.SetItem(idx, 3, item['modified'])
def _finalize_list_loading(self, items_data):
if not self: return
self.current_items = items_data
self.apply_sorting()
self.set_status(self.get_txt("status_ready"))
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, finalize=True):
if not self: return
try:
self.current_items = items_data
if finalize:
# Anvend sortering før visning
self.apply_sorting()
else:
# Bare vis de nuværende usorteret (for hurtigere feedback)
self._update_list_view_only()
# Opdater tilstand (kun første gang)
if parent_data:
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))
if finalize:
self.set_status(self.get_txt("status_ready"))
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 _sync_tree_selection_by_path(self, path):
"""Forsøg at finde og vælge en node i træet baseret på en sti af navne."""
if not path or path[0] != "SharePoint":
return
segments = path[1:]
curr_node = self.tree_root
for name in segments:
found = False
child, cookie = self.tree_ctrl.GetFirstChild(curr_node)
while child.IsOk():
if self.tree_ctrl.GetItemText(child) == name:
curr_node = child
found = True
break
child, cookie = self.tree_ctrl.GetNextChild(curr_node, cookie)
if not found:
return # Stop hvis stien ikke findes/er indlæst i træet
# Hvis vi nåede hertil, har vi fundet den korrekte node
self.tree_ctrl.SelectItem(curr_node)
self.tree_ctrl.EnsureVisible(curr_node)
def on_item_activated(self, event):
item_idx = event.GetIndex()
item = self.current_items[item_idx]
if item['type'] in ["SITE", "DRIVE", "FOLDER"]:
# Prøv at finde og vælge den i træet (hvilket trigger on_tree_selected)
if not self._sync_tree_selection(item['id']):
# Hvis den allerede var valgt eller ikke findes i træet, tvinger vi navigationen
self._navigate_to_item_data(item)
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)
# Hvis vi allerede HAR valgt den rigtige node, så returner False (så on_item_activated tvinger refresh)
if data and data['id'] == target_id:
return False
if data and not data.get("loaded"):
self._pending_tree_selection_id = target_id
self.tree_ctrl.Expand(selected)
return True # Vi har sat en handling i gang
child, cookie = self.tree_ctrl.GetFirstChild(selected)
while child.IsOk():
cdata = self.tree_item_data.get(child)
if cdata and cdata['id'] == target_id:
if self.tree_ctrl.GetSelection() == child:
return False # Allerede valgt
self.tree_ctrl.SelectItem(child)
return True # Selection changed
child, cookie = self.tree_ctrl.GetNextChild(selected, cookie)
return False
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, item.get('hash')), 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, remote_hash=None):
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(get_long_path(local_path), 'wb') as f:
f.write(res.content)
# Verificer integritet og gem hash til senere sammenligning
original_hash = None
if remote_hash and ENABLE_HASH_VALIDATION:
file_size = os.path.getsize(get_long_path(local_path))
if file_size <= (HASH_THRESHOLD_MB * 1024 * 1024):
# Vi bruger fjern-hash direkte som vores 'original', hvis den er tilgængelig.
# Vi tjekker dog lige at downloaden rent faktisk matchede.
local_check = quickxorhash(local_path)
if local_check == remote_hash:
original_hash = remote_hash
logger.info(f"Download ok for {file_name}. Bruger XOR hash til ændrings-detektering.")
else:
logger.warning(f"Hash mismatch efter download af {file_name}!")
self.show_info(f"Advarsel: Filens integritet kunne ikke bekræftes (XorHash mismatch)", wx.ICON_WARNING)
original_hash = local_check
# Hvis vi ikke beregnede hash pga. størrelse eller manglende remote_hash, gør det nu for lokal detektering
if original_hash is None:
# Her bruger vi SHA256 af hastighedsårsager til lokal sammenligning (før/efter)
sha256 = hashlib.sha256()
with open(get_long_path(local_path), 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk: break
sha256.update(chunk)
original_hash = "SHA256:" + sha256.hexdigest()
logger.info(f"Bruger lokal SHA256 til ændrings-detektering for {file_name}")
# Checkout
is_checked_out = False
checkout_res = requests.post(f"{base_url}/checkout", headers=self.headers)
if checkout_res.status_code in [200, 201, 204]:
is_checked_out = True
logger.info(f"Fil {file_name} udtjekket succesfuldt.")
else:
logger.warning(f"Kunne ikke udtjekke {file_name} (Status: {checkout_res.status_code}). Fortsætter dog...")
# 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
if original_hash.startswith("SHA256:"):
sha256 = hashlib.sha256()
with open(get_long_path(local_path), 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk: break
sha256.update(chunk)
new_hash = "SHA256:" + sha256.hexdigest()
else:
new_hash = quickxorhash(local_path)
if original_hash == new_hash:
logger.info(f"Ingen ændringer fundet i {file_name}. (Hash: {new_hash[:16]}...) Springer upload over.")
self.set_status(self.get_txt("msg_file_unchanged"))
if is_checked_out:
logger.info(f"Annullerer udtjekning (discardCheckout) for {file_name}...")
res = requests.post(f"{base_url}/discardCheckout", headers=self.headers)
if res.status_code in [200, 204]:
is_checked_out = False
else:
# 5. Upload (kun hvis ændret)
logger.info(f"Ændring fundet! Uploader {file_name}...")
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 (Kun hvis vi faktisk uploadede noget)
if is_checked_out:
self.set_status(self.get_txt("msg_checking_in", name=file_name))
res = requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"})
if res.status_code in [200, 201, 204]:
is_checked_out = False
# 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 is_checked_out:
# Emergency cleanup hvis vi stadig har fat i filen (f.eks. ved crash eller afbrydelse)
logger.info(f"Rydder op: Kalder discardCheckout for {file_name}...")
requests.post(f"{base_url}/discardCheckout", headers=self.headers)
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()