Fix #1: Don't sleep after the final exhausted retry attempt in _graph_request(). The sleep on the last iteration was pure overhead — the loop exits immediately after, so the caller just waited an extra backoff period for no reason. Guard with attempt < _MAX_RETRIES - 1. Task 10: Add threading.Lock (_edits_lock) for all compound operations on active_edits, which is accessed from both the UI thread and background edit threads: - __init__: declare self._edits_lock = threading.Lock() - open_file: snapshot already_editing and at_limit under the lock, then release before showing blocking UI dialogs - process_file: wrap initial dict assignment, waiting=True, the in-check+waiting=False, and in-check+del under the lock - on_done_editing_clicked: lock the items() snapshot used to build waiting_files, preventing iteration over a dict being mutated Add 8 new unit tests (1 for Fix #1 sleep count, 7 for Task 10 lock). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2469 lines
110 KiB
Python
2469 lines
110 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 Exception:
|
|
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)
|
|
|
|
# --- GRAPH API REQUEST HELPER ---
|
|
_RETRY_STATUSES = {429, 503}
|
|
_MAX_RETRIES = 3
|
|
|
|
def _graph_request(method, url, **kwargs):
|
|
"""Thin wrapper around requests.request with retry for 429/503.
|
|
|
|
Retries up to _MAX_RETRIES times when Graph API signals rate limiting
|
|
(429) or transient unavailability (503), honouring the Retry-After header
|
|
when present. A default timeout of 30 s is injected if the caller does
|
|
not supply one. File-upload calls that pass an open stream as data=
|
|
should use requests.put() directly, since a stream cannot be re-read.
|
|
"""
|
|
kwargs.setdefault("timeout", 30)
|
|
for attempt in range(_MAX_RETRIES):
|
|
res = requests.request(method, url, **kwargs)
|
|
if res.status_code not in _RETRY_STATUSES:
|
|
return res
|
|
if attempt < _MAX_RETRIES - 1: # Don't sleep after the final attempt
|
|
retry_after = int(res.headers.get("Retry-After", 2 ** attempt))
|
|
time.sleep(min(retry_after, 60))
|
|
return res # Return last response after exhausting retries
|
|
|
|
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)",
|
|
"status_loading_items": "Henter... ({count} emner)",
|
|
"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)",
|
|
"status_loading_items": "Loading... ({count} items)",
|
|
"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, self.get_txt("settings_logging_group").split("/")[0].strip())
|
|
|
|
# 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._nav_gen = 0 # Incremented on each navigation to discard stale fetch results
|
|
self.tree_root = None
|
|
self.is_navigating_back = False
|
|
self.active_edits = {} # item_id -> { "name": name, "event": Event, "waiting": bool }
|
|
self._edits_lock = threading.Lock()
|
|
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:
|
|
logger.error(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 Exception:
|
|
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()
|
|
|
|
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 = _graph_request("DELETE", url, headers=self.headers, timeout=30)
|
|
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, timeout=120)
|
|
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 = _graph_request("POST", url, headers=self.headers, json=body, timeout=30)
|
|
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, timeout=120)
|
|
|
|
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 = _graph_request("PATCH", url, headers=self.headers, json=body, timeout=30)
|
|
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, os.path.basename(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 = _graph_request("GET", url, headers=self.headers, timeout=30)
|
|
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 = _graph_request("GET", url, headers=self.headers, timeout=30)
|
|
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, os.path.basename(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()
|
|
rect = self.status_bar.GetFieldRect(1)
|
|
self.gauge.SetPosition((rect.x + 5, rect.y + 2))
|
|
self.gauge.SetSize((rect.width - 10, rect.height - 4))
|
|
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):
|
|
with self._edits_lock:
|
|
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)
|
|
|
|
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)
|
|
# 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:
|
|
logger.error(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 = _graph_request("GET", url, headers=self.headers, timeout=30)
|
|
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)}...)")
|
|
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 = []
|
|
url = None
|
|
|
|
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 = _graph_request("GET", url, headers=self.headers, timeout=30)
|
|
if res.status_code == 200:
|
|
res_data = res.json()
|
|
all_children.extend(res_data.get('value', []))
|
|
url = res_data.get('@odata.nextLink')
|
|
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):
|
|
try:
|
|
# Race-condition beskyttelse: Hvis vi allerede er der, så stop
|
|
if 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"))
|
|
|
|
self._nav_gen += 1
|
|
gen = self._nav_gen
|
|
threading.Thread(target=self._fetch_list_contents_bg, args=(data, gen), daemon=True).start()
|
|
except RuntimeError:
|
|
pass
|
|
|
|
def _fetch_list_contents_bg(self, data, nav_gen=None):
|
|
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 = _graph_request("GET", url, headers=self.headers, timeout=30)
|
|
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(self.get_txt("status_loading_items").format(count=len(items_data)))
|
|
|
|
# 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, nav_gen)
|
|
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'] == "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, nav_gen=None):
|
|
if not self: return
|
|
if nav_gen is not None and nav_gen != self._nav_gen:
|
|
return # User navigated away; discard stale results
|
|
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 Exception:
|
|
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']
|
|
|
|
with self._edits_lock:
|
|
already_editing = item_id in self.active_edits
|
|
at_limit = len(self.active_edits) >= 10
|
|
|
|
# UI dialogs are called outside the lock to avoid holding it during blocking calls
|
|
if already_editing:
|
|
self.show_info(f"'{file_name}' er allerede ved at blive redigeret.", wx.ICON_INFORMATION)
|
|
return
|
|
if at_limit:
|
|
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()
|
|
with self._edits_lock:
|
|
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 = _graph_request("GET", f"{base_url}/content", headers=self.headers, timeout=30)
|
|
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 = _graph_request("POST", f"{base_url}/checkout", headers=self.headers, timeout=30)
|
|
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()
|
|
with self._edits_lock:
|
|
self.active_edits[item_id]["waiting"] = True
|
|
self.update_edit_ui()
|
|
|
|
edit_event.wait()
|
|
|
|
with self._edits_lock:
|
|
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 = _graph_request("POST", f"{base_url}/discardCheckout", headers=self.headers, timeout=30)
|
|
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, timeout=120)
|
|
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 = _graph_request("POST", f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"}, timeout=30)
|
|
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 Exception:
|
|
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}...")
|
|
_graph_request("POST", f"{base_url}/discardCheckout", headers=self.headers, timeout=30)
|
|
|
|
with self._edits_lock:
|
|
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() |