1330 lines
59 KiB
Python
1330 lines
59 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
|
||
|
||
# --- 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
|
||
}
|
||
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",
|
||
"status_login_needed": "Session udløbet. Log ind igen."
|
||
},
|
||
"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",
|
||
"status_login_needed": "Session expired. Please login again."
|
||
}
|
||
}
|
||
|
||
if not os.path.exists(TEMP_DIR):
|
||
os.makedirs(TEMP_DIR)
|
||
|
||
class UploadDropTarget(wx.FileDropTarget):
|
||
def __init__(self, window, app):
|
||
wx.FileDropTarget.__init__(self)
|
||
self.window = window
|
||
self.app = app
|
||
|
||
def OnDropFiles(self, x, y, filenames):
|
||
if not self.app.current_drive_id:
|
||
wx.MessageBox(self.app.get_txt("msg_drop_info"),
|
||
self.app.get_txt("msg_drop_title"), wx.OK | wx.ICON_INFORMATION)
|
||
return False
|
||
|
||
for path in filenames:
|
||
if os.path.isfile(path):
|
||
threading.Thread(target=self.app._upload_file_bg,
|
||
args=(path, self.app.current_drive_id, self.app.current_folder_id),
|
||
daemon=True).start()
|
||
elif os.path.isdir(path):
|
||
threading.Thread(target=self.app._upload_folder_bg,
|
||
args=(path, self.app.current_drive_id, self.app.current_folder_id),
|
||
daemon=True).start()
|
||
return True
|
||
|
||
def get_file_hash(path):
|
||
if not os.path.exists(path):
|
||
return None
|
||
sha256_hash = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
for byte_block in iter(lambda: f.read(4096), b""):
|
||
sha256_hash.update(byte_block)
|
||
return sha256_hash.hexdigest()
|
||
|
||
def format_size(bytes_num):
|
||
if bytes_num is None:
|
||
return ""
|
||
if bytes_num < 1024:
|
||
return f"{bytes_num} B"
|
||
elif bytes_num < 1024**2:
|
||
return f"{bytes_num/1024:.1f} KB"
|
||
elif bytes_num < 1024**3:
|
||
return f"{bytes_num/(1024**2):.1f} MB"
|
||
else:
|
||
return f"{bytes_num/(1024**3):.1f} GB"
|
||
|
||
class SharePointApp(wx.Frame):
|
||
def __init__(self):
|
||
self.lang = CURRENT_LANG
|
||
super().__init__(None, title=self.get_txt("title"), size=(1000, 750))
|
||
|
||
# State
|
||
self.access_token = None
|
||
self.headers = {}
|
||
self.history = [] # Stack af (mode, id, path_segment)
|
||
self.current_path = ["SharePoint"]
|
||
self.current_site_id = None
|
||
self.current_drive_id = None
|
||
self.current_folder_id = "root"
|
||
self.current_items = [] # Gemmer graf-objekterne for rækkerne
|
||
self.tree_item_data = {} # Mappenoder -> {type, id, name, drive_id, path}
|
||
self.tree_root = None
|
||
self.is_navigating_back = False
|
||
self.active_edits = {} # item_id -> { "name": name, "event": Event, "waiting": bool }
|
||
|
||
# System Ikoner (ArtProvider - mest basale for kompatibilitet)
|
||
self.image_list = wx.ImageList(16, 16)
|
||
self.idx_site = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_GO_HOME, wx.ART_OTHER, (16, 16))) # Site (Hus ikon)
|
||
self.idx_drive = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_HARDDISK, wx.ART_OTHER, (16, 16))) # Drive
|
||
self.idx_folder = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_FOLDER, wx.ART_OTHER, (16, 16))) # Folder
|
||
self.idx_file = self.image_list.Add(wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, (16, 16))) # File
|
||
|
||
# Threading/Sync til filredigering
|
||
|
||
# MSAL Cache
|
||
self.msal_app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
|
||
|
||
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))
|
||
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=(100, 30))
|
||
self.back_btn.Disable()
|
||
self.back_btn.Bind(wx.EVT_BUTTON, self.go_back)
|
||
nav_hbox.Add(self.back_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||
|
||
self.home_btn = wx.Button(nav_panel, label=self.get_txt("btn_home"), size=(100, 30))
|
||
self.home_btn.Disable()
|
||
self.home_btn.Bind(wx.EVT_BUTTON, self.load_sites)
|
||
nav_hbox.Add(self.home_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||
|
||
self.refresh_btn = wx.Button(nav_panel, label=self.get_txt("btn_refresh"), size=(100, 30))
|
||
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=(200, 30))
|
||
self.done_btn.SetBackgroundColour(wx.Colour(255, 69, 0)) # OrangeRed
|
||
self.done_btn.SetForegroundColour(wx.WHITE)
|
||
self.done_btn.Hide()
|
||
self.done_btn.Bind(wx.EVT_BUTTON, self.on_done_editing_clicked)
|
||
nav_hbox.Add(self.done_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||
|
||
# NYE KNAPPER: Upload og Ny Mappe (Vises kun når man er inde i et drev/mappe)
|
||
self.upload_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_file"), size=(120, 30))
|
||
self.upload_btn.Hide()
|
||
self.upload_btn.Bind(wx.EVT_BUTTON, self.on_upload_clicked)
|
||
nav_hbox.Add(self.upload_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||
|
||
self.upload_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_upload_folder"), size=(120, 30))
|
||
self.upload_folder_btn.Hide()
|
||
self.upload_folder_btn.Bind(wx.EVT_BUTTON, self.on_upload_folder_clicked)
|
||
nav_hbox.Add(self.upload_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||
|
||
self.new_folder_btn = wx.Button(nav_panel, label=self.get_txt("btn_new_folder"), size=(100, 30))
|
||
self.new_folder_btn.Hide()
|
||
self.new_folder_btn.Bind(wx.EVT_BUTTON, self.on_new_folder_clicked)
|
||
nav_hbox.Add(self.new_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||
|
||
nav_hbox.AddStretchSpacer(1)
|
||
|
||
self.login_btn = wx.Button(nav_panel, label=self.get_txt("btn_login"), size=(120, 30))
|
||
self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn
|
||
self.login_btn.SetForegroundColour(wx.WHITE)
|
||
self.login_btn.Bind(wx.EVT_BUTTON, self.login)
|
||
nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||
|
||
# SPROG VÆLGER
|
||
self.lang_choice = wx.Choice(nav_panel, choices=["Dansk", "English"])
|
||
self.lang_choice.SetSelection(0 if self.lang == "da" else 1)
|
||
self.lang_choice.Bind(wx.EVT_CHOICE, self.on_language_changed)
|
||
nav_hbox.Add(self.lang_choice, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||
|
||
nav_panel.SetSizer(nav_hbox)
|
||
vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5)
|
||
|
||
# 2. PATH BREADCRUMBS (Adresselinje-stil)
|
||
self.path_panel = wx.Panel(panel, style=wx.BORDER_SIMPLE)
|
||
self.path_panel.SetBackgroundColour(wx.WHITE)
|
||
self.path_panel.SetMinSize((-1, 38)) # Sikrer synbarhed
|
||
self.path_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||
self.path_panel.SetSizer(self.path_sizer)
|
||
vbox.Add(self.path_panel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
|
||
|
||
# 3. SPLITTER FOR TREE AND LIST
|
||
self.splitter = wx.SplitterWindow(panel, style=wx.SP_LIVE_UPDATE | wx.SP_3DSASH)
|
||
|
||
# Left side: Tree
|
||
self.tree_ctrl = wx.TreeCtrl(self.splitter, style=wx.TR_DEFAULT_STYLE | wx.TR_HIDE_ROOT | wx.BORDER_SUNKEN)
|
||
self.tree_ctrl.AssignImageList(self.image_list)
|
||
self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_EXPANDING, self.on_tree_expanding)
|
||
self.tree_ctrl.Bind(wx.EVT_TREE_SEL_CHANGED, self.on_tree_selected)
|
||
self.tree_ctrl.Bind(wx.EVT_TREE_ITEM_MENU, self.on_tree_right_click)
|
||
|
||
# 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_CONTEXT_MENU, self.on_right_click)
|
||
|
||
# AKTIVER DRAG & DROP
|
||
self.list_ctrl.SetDropTarget(UploadDropTarget(self.list_ctrl, self))
|
||
|
||
self.splitter.SplitVertically(self.tree_ctrl, self.list_ctrl, 250)
|
||
self.splitter.SetMinimumPaneSize(100)
|
||
|
||
vbox.Add(self.splitter, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
|
||
|
||
# 4. STATUS BAR
|
||
self.status_bar = self.CreateStatusBar()
|
||
self.status_bar.SetStatusText(self.get_txt("status_ready"))
|
||
|
||
panel.SetSizer(vbox)
|
||
self.Layout()
|
||
|
||
def on_right_click(self, event):
|
||
selected_indices = []
|
||
idx = self.list_ctrl.GetFirstSelected()
|
||
while idx != -1:
|
||
selected_indices.append(idx)
|
||
idx = self.list_ctrl.GetNextSelected(idx)
|
||
|
||
menu = wx.Menu()
|
||
|
||
if selected_indices:
|
||
# Menu for de valgte emner
|
||
selected_items = [self.current_items[i] for i in selected_indices]
|
||
|
||
if len(selected_indices) == 1:
|
||
item = selected_items[0]
|
||
|
||
if item.get("web_url"):
|
||
browser_item = menu.Append(wx.ID_ANY, self.get_txt("msg_open_browser"))
|
||
self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(item["web_url"]), browser_item)
|
||
|
||
download_item = menu.Append(wx.ID_ANY, self.get_txt("msg_download"))
|
||
self.Bind(wx.EVT_MENU, lambda e: self.on_download_clicked(item), download_item)
|
||
|
||
menu.AppendSeparator()
|
||
|
||
if item['type'] == "FILE":
|
||
edit_item = menu.Append(wx.ID_ANY, self.get_txt("msg_edit_file"))
|
||
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']}'")
|
||
self.Bind(wx.EVT_MENU, lambda e: self.on_rename_clicked(item), rename_item)
|
||
|
||
delete_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} '{item['name']}'")
|
||
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_item)
|
||
else:
|
||
# Flere emner valgt
|
||
delete_items = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} {len(selected_indices)} " + ("emner" if self.lang == "da" else "items"))
|
||
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_items)
|
||
else:
|
||
# Menu for selve mappen (hvis man trykker på det tomme felt)
|
||
if self.current_drive_id:
|
||
upload_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_here"))
|
||
self.Bind(wx.EVT_MENU, self.on_upload_clicked, upload_item)
|
||
|
||
upload_dir_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_folder_here"))
|
||
self.Bind(wx.EVT_MENU, self.on_upload_folder_clicked, upload_dir_item)
|
||
|
||
new_folder_item = menu.Append(wx.ID_ANY, self.get_txt("msg_new_folder_here"))
|
||
self.Bind(wx.EVT_MENU, self.on_new_folder_clicked, new_folder_item)
|
||
|
||
# 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"))
|
||
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)
|
||
menu = wx.Menu()
|
||
refresh_item = menu.Append(wx.ID_ANY, self.get_txt("btn_refresh"))
|
||
self.Bind(wx.EVT_MENU, self.on_refresh, refresh_item)
|
||
self.PopupMenu(menu)
|
||
menu.Destroy()
|
||
|
||
# --- FILHÅNDTERING (Upload, Slet, Ny Mappe) ---
|
||
def on_delete_items_clicked(self, items):
|
||
if not items: return
|
||
|
||
names = ", ".join([f"'{i['name']}'" for i in items[:3]])
|
||
if len(items) > 3:
|
||
names += f" og {len(items)-3} andre..." if self.lang == "da" else f" and {len(items)-3} others..."
|
||
|
||
msg = self.get_txt("msg_confirm_delete_multi", num=len(items), names=names)
|
||
res = wx.MessageBox(msg, self.get_txt("msg_delete_title"), wx.YES_NO | wx.ICON_WARNING)
|
||
if res == wx.YES:
|
||
threading.Thread(target=self._delete_multiple_bg, args=(items,), daemon=True).start()
|
||
|
||
def _delete_multiple_bg(self, items):
|
||
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_language_changed(self, event):
|
||
selection = self.lang_choice.GetSelection()
|
||
self.lang = "da" if selection == 0 else "en"
|
||
|
||
# Gem til settings
|
||
settings["language"] = self.lang
|
||
save_settings(settings)
|
||
|
||
# Opdater UI tekster med det samme
|
||
self.SetTitle(self.get_txt("title"))
|
||
self.back_btn.SetLabel(self.get_txt("btn_back"))
|
||
self.home_btn.SetLabel(self.get_txt("btn_home"))
|
||
self.done_btn.SetLabel(self.get_txt("btn_save_changes"))
|
||
self.upload_btn.SetLabel(self.get_txt("btn_upload_file"))
|
||
self.upload_folder_btn.SetLabel(self.get_txt("btn_upload_folder"))
|
||
self.new_folder_btn.SetLabel(self.get_txt("btn_new_folder"))
|
||
self.refresh_btn.SetLabel(self.get_txt("btn_refresh"))
|
||
|
||
if self.access_token:
|
||
self.login_btn.SetLabel(self.get_txt("btn_logged_in"))
|
||
else:
|
||
self.login_btn.SetLabel(self.get_txt("btn_login"))
|
||
|
||
# Opdater kolonner i ListCtrl
|
||
self.list_ctrl.SetColumnWidth(0, 450) # Refresh widths
|
||
item = self.list_ctrl.GetColumn(0)
|
||
self.list_ctrl.SetColumn(0, wx.ListItem()) # Reset column header logic could be complex in wx,
|
||
# but the simplest is to just re-insert columns or set text
|
||
|
||
# Re-set headers (Fix: explicitly set image to -1 to avoid icons in headers)
|
||
cols = [self.get_txt("col_name"), self.get_txt("col_type"), self.get_txt("col_size"), self.get_txt("col_modified")]
|
||
for i, text in enumerate(cols):
|
||
info = self.list_ctrl.GetColumn(i)
|
||
info.SetText(text)
|
||
info.SetImage(-1)
|
||
self.list_ctrl.SetColumn(i, info)
|
||
|
||
self.set_status(self.get_txt("status_ready"))
|
||
self._refresh_current_view() # Gendanner list-item tekster (Mappe/Fil)
|
||
|
||
def on_close_window(self, event):
|
||
if self.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 update_path_display(self):
|
||
if not self:
|
||
return
|
||
|
||
try:
|
||
self.path_sizer.Clear(True)
|
||
|
||
# Find alle noder fra rod til nuværende selektion
|
||
nodes = []
|
||
curr = self.tree_ctrl.GetSelection()
|
||
|
||
while curr.IsOk() and curr != self.tree_root:
|
||
nodes.insert(0, curr)
|
||
curr = self.tree_ctrl.GetItemParent(curr)
|
||
|
||
# Start ikon/label
|
||
self._add_path_segment("📍 " + self.get_txt("title"), "ROOT")
|
||
|
||
for node in nodes:
|
||
arrow = wx.StaticText(self.path_panel, label=" > ")
|
||
arrow.SetForegroundColour(wx.Colour(150, 150, 150))
|
||
self.path_sizer.Add(arrow, 0, wx.ALIGN_CENTER_VERTICAL)
|
||
|
||
name = self.tree_ctrl.GetItemText(node)
|
||
self._add_path_segment(name, node)
|
||
|
||
self.path_panel.Layout()
|
||
self.path_panel.Refresh()
|
||
self.Layout() # Tving rammen til at opdatere, så stien kommer frem
|
||
except RuntimeError:
|
||
# Sker oftest ved lukning hvor objekter er slettet
|
||
pass
|
||
|
||
def _add_path_segment(self, label, node):
|
||
btn = wx.Button(self.path_panel, label=label, style=wx.BU_EXACTFIT | wx.BORDER_NONE)
|
||
btn.SetBackgroundColour(wx.WHITE)
|
||
btn.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL))
|
||
|
||
if node == "ROOT":
|
||
btn.Bind(wx.EVT_BUTTON, self.load_sites)
|
||
elif node:
|
||
btn.Bind(wx.EVT_BUTTON, lambda e: self.tree_ctrl.SelectItem(node))
|
||
|
||
self.path_sizer.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||
|
||
def 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
|
||
}
|
||
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
|
||
}
|
||
self.tree_ctrl.SetItemHasChildren(node, True)
|
||
if drive_id == getattr(self, '_pending_tree_selection_id', None):
|
||
target_node = node
|
||
|
||
if target_node:
|
||
self._pending_tree_selection_id = None
|
||
self.tree_ctrl.SelectItem(target_node)
|
||
|
||
def _populate_tree_folders(self, parent_node, folders, parent_data):
|
||
self.tree_ctrl.DeleteChildren(parent_node)
|
||
parent_data["loaded"] = True
|
||
target_node = None
|
||
|
||
for folder in folders:
|
||
name = folder['name']
|
||
folder_id = folder['id']
|
||
node = self.tree_ctrl.AppendItem(parent_node, name, image=self.idx_folder)
|
||
self.tree_item_data[node] = {
|
||
"type": "FOLDER", "id": folder_id, "name": name,
|
||
"drive_id": parent_data["drive_id"], "path": parent_data["path"] + [name], "loaded": False
|
||
}
|
||
self.tree_ctrl.SetItemHasChildren(node, True)
|
||
if folder_id == getattr(self, '_pending_tree_selection_id', None):
|
||
target_node = node
|
||
|
||
if target_node:
|
||
self._pending_tree_selection_id = None
|
||
self.tree_ctrl.SelectItem(target_node)
|
||
|
||
def on_tree_selected(self, event):
|
||
if not self: 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 _populate_list_ctrl(self, items_data, parent_data):
|
||
if not self: return
|
||
try:
|
||
self.list_ctrl.DeleteAllItems()
|
||
self.current_items = []
|
||
for i, item in enumerate(items_data):
|
||
img_idx = self.idx_file
|
||
if item['type'] == "FOLDER": img_idx = self.idx_folder
|
||
elif item['type'] == "DRIVE": img_idx = self.idx_drive
|
||
elif item['type'] == "SITE": img_idx = self.idx_site
|
||
|
||
self.list_ctrl.InsertItem(i, item['name'], img_idx)
|
||
type_str = self.get_txt("type_folder") if item['type'] == "FOLDER" else self.get_txt("type_file") if item['type'] == "FILE" else self.get_txt("type_drive")
|
||
self.list_ctrl.SetItem(i, 1, type_str)
|
||
size_str = format_size(item['size']) if item['size'] is not None else ""
|
||
self.list_ctrl.SetItem(i, 2, size_str)
|
||
self.list_ctrl.SetItem(i, 3, item['modified'])
|
||
self.current_items.append(item)
|
||
|
||
self.set_status(self.get_txt("status_ready"))
|
||
|
||
if parent_data['type'] == "SITE":
|
||
self.current_site_id = parent_data['id']
|
||
elif parent_data['type'] == "DRIVE":
|
||
self.current_drive_id = parent_data['id']
|
||
self.current_folder_id = "root"
|
||
elif parent_data['type'] == "FOLDER":
|
||
self.current_drive_id = parent_data['drive_id']
|
||
self.current_folder_id = parent_data['id']
|
||
|
||
# Opdater knap-synlighed
|
||
can_upload = self.current_drive_id is not None
|
||
wx.CallAfter(lambda: self._safe_update_buttons(can_upload))
|
||
except RuntimeError:
|
||
pass
|
||
|
||
def _safe_update_buttons(self, can_upload):
|
||
try:
|
||
if not self: return
|
||
self.upload_btn.Show(can_upload)
|
||
self.upload_folder_btn.Show(can_upload)
|
||
self.new_folder_btn.Show(can_upload)
|
||
self.Layout()
|
||
except RuntimeError:
|
||
pass
|
||
|
||
def on_item_activated(self, event):
|
||
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()
|