From 741a7ee4894969a6be170a541ec4b82386404a1b Mon Sep 17 00:00:00 2001 From: Martin Tranberg Date: Mon, 30 Mar 2026 15:18:48 +0200 Subject: [PATCH] Initial commit: SharePoint Explorer with modern UI and settings.json support --- .gitignore | 46 +++++++ GEMINI.md | 65 ++++++++++ README.md | 44 +++++++ project_description.md | 42 ++++++ sharepoint_browser.py | 284 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 .gitignore create mode 100644 GEMINI.md create mode 100644 README.md create mode 100644 project_description.md create mode 100644 sharepoint_browser.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d26310f --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +bin/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Local settings (keep your secrets safe) +settings.json + +# Temporary files +C:\Temp_SP\ diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..828a2a9 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,65 @@ +# SharePoint Browser + +A modern Python-based file browser for Microsoft SharePoint, specifically designed to bypass the Windows `MAX_PATH` (260 character) limitation. It achieves this by interacting directly with the Microsoft Graph API using unique IDs and downloading files to short, temporary local paths for editing. + +## Project Overview + +- **Purpose:** Provide a seamless SharePoint browsing and editing experience regardless of folder depth. +- **Key Strategy:** Uses unique Microsoft Graph API IDs instead of traditional file paths to avoid path length issues. +- **Core Workflow:** + 1. Authenticate via MSAL. + 2. Browse SharePoint sites/folders dynamically. + 3. **Checkout** a file on SharePoint. + 4. **Download** to a short local path (e.g., `C:\Temp_SP\[ShortHash].[ext]`). + 5. **Monitor** local file usage; detect when the editing application is closed. + 6. **Upload** the modified file back to SharePoint. + 7. **Checkin** and clean up local temporary files. + +## Tech Stack + +- **Language:** Python 3.x +- **GUI Framework:** [CustomTkinter](https://github.com/TomSchimansky/CustomTkinter) (Modern, responsive UI) +- **Authentication:** [MSAL (Microsoft Authentication Library)](https://github.com/AzureAD/microsoft-authentication-library-for-python) +- **API Interaction:** Microsoft Graph API via `requests` +- **File Monitoring:** Local file lock polling + +## Getting Started + +### Prerequisites + +Ensure you have Python installed. You will also need to install the following dependencies: + +```bash +pip install customtkinter msal requests +``` + +### Running the Application + +Execute the main script: + +```bash +python sharepoint_browser.py +``` + +### Configuration + +The application is pre-configured with a `CLIENT_ID` and `TENANT_ID` for Microsoft authentication. These are located at the top of `sharepoint_browser.py`: + +```python +CLIENT_ID = '281c91fc-4572-4614-b4d2-e3795d4b502d' +TENANT_ID = 'cce9d2ae-239b-4a22-86bb-fb904c85be79' +TEMP_DIR = "C:\\Temp_SP" +``` + +## Architecture & Conventions + +- **Main Entry Point:** `sharepoint_browser.py` contains both the GUI logic and the Graph API integration. +- **Threading:** File operations (download, wait, upload) are executed in background threads (`threading.Thread`) to keep the GUI responsive. +- **File Detection:** Uses a polling mechanism with `os.rename(local_path, local_path)` to detect when the external editor (e.g., Word, Excel) has released the file lock. +- **Path Handling:** Generates unique local filenames using MD5 hashes of the SharePoint item IDs to ensure they remain short and avoid collisions. + +## Key Files + +- `sharepoint_browser.py`: The complete application logic. +- `project_description.md`: Original project requirements and context (in Danish). +- `GEMINI.md`: This instructional context file. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da54878 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# SharePoint Explorer + +En moderne Python-baseret fil-browser til Microsoft SharePoint, designet til at omgå Windows' `MAX_PATH` (260 karakterer) begrænsning. + +## Funktioner +- **Søg og Browse:** Naviger dynamisk gennem SharePoint sites, dokumentbiblioteker og mapper. +- **Sikker Redigering:** Automatisk Check-out/Check-in håndtering via Microsoft Graph API. +- **Explorer Vibes:** Moderne brugerflade med sortering (mapper øverst) og brødkrummesti (breadcrumb). +- **Ingen Sti-begrænsning:** Arbejder med unikke ID'er og korte midlertidige stier for at undgå MAX_PATH fejl. + +## Installation & Udvikling + +### Forudsætninger +- Python 3.x +- Microsoft 365 licens (Business Standard eller højere anbefales) + +### Setup +1. Installer afhængigheder: + ```bash + pip install customtkinter msal requests + ``` +2. Konfigurer `settings.json` med din `client_id` og `tenant_id`. + +### Kørsel +```bash +python sharepoint_browser.py +``` + +## Kompilering til EXE +For at pakke programmet til en enkelt selvstændig `.exe` fil: +```bash +pip install pyinstaller +python -m PyInstaller --noconsole --onefile --collect-all customtkinter --name "SharePoint Explorer" sharepoint_browser.py +``` +Den færdige fil findes i mappen `dist/`. Husk at placere `settings.json` i samme mappe som `.exe` filen. + +## Konfiguration (`settings.json`) +```json +{ + "client_id": "DIN_CLIENT_ID", + "tenant_id": "DIN_TENANT_ID", + "temp_dir": "C:\\Temp_SP" +} +``` diff --git a/project_description.md b/project_description.md new file mode 100644 index 0000000..a690872 --- /dev/null +++ b/project_description.md @@ -0,0 +1,42 @@ +Projektbeskrivelse: Python SharePoint File Browser (Modern GUI) +Kontekst og Formål: +Udvikl en fil-browser med en moderne grafisk brugerflade i Python. Formålet er at løse problemet med Windows', SharePoints og OneDrives begrænsning på stinavne (over 260 karakterer / MAX_PATH). Programmet skal omgå denne grænse ved at arbejde med filernes unikke ID'er og URL'er via Microsoft Graph API frem for at bruge traditionelle filstier. + +Teknologier: + +Sprog: Python + +GUI-framework: CustomTkinter (eller PyQt6) for et moderne, responsivt design. + +Integration: Microsoft Graph API (Autentificering via MSAL). + +Filhåndtering: Windows OS procesovervågning og native fil-operationer. + +Kernefunktioner og User Flow: + +Autentificering: Sikker login via MSAL (Microsoft Authentication Library) til brugerens Microsoft 365 / SharePoint konto. + +Navigation (UI): En moderne grænseflade, hvor brugeren kan browse SharePoint-sites, mapper og filer dynamisk – uanset hvor dyb mappestrukturen er. + +Sikker stihåndtering & Check-out: Når en fil vælges til redigering: + +Udfør et "Check-out" via Graph API, så filen låses for andre brugere på SharePoint. + +Download filen til en midlertidig, meget kort lokal sti (f.eks. C:\Temp\[KortHash].[ext]) for at undgå MAX_PATH-begrænsningen i Windows. + +Lokal Redigering & Overvågning: + +Åbn den downloadede fil med standardprogrammet i Windows (f.eks. Word til .docx). + +Programmet skal overvåge den specifikke proces eller fillås og registrere, præcis hvornår brugeren lukker programmet/filen igen. + +Gen-upload & Check-in: + +Når filen er lukket lokalt, uploades den ændrede fil automatisk tilbage til den oprindelige placering via Graph API. + +Udfør et "Check-in" på SharePoint for at frigive filen til andre. + +Slet den midlertidige lokale fil for at rydde op. + +Krav til output: +Jeg ønsker det komplette, kørbare Python-script og ikke kun delektioner eller ændringer. Undlad for meget forklarende tekst, og fokuser på at levere en robust og fuldstændig kode. \ No newline at end of file diff --git a/sharepoint_browser.py b/sharepoint_browser.py new file mode 100644 index 0000000..4775d2b --- /dev/null +++ b/sharepoint_browser.py @@ -0,0 +1,284 @@ +import os +import time +import threading +import hashlib +import json +import sys +import requests +import msal +import customtkinter as ctk +import tkinter as tk +from tkinter import messagebox + +# --- STIHÅNDTERING (Til EXE-brug) --- +if getattr(sys, 'frozen', False): + # Vi kører som en kompileret .exe + base_dir = os.path.dirname(sys.executable) +else: + # Vi kører som normalt script + base_dir = os.path.dirname(os.path.abspath(__file__)) + +SETTINGS_FILE = os.path.join(base_dir, 'settings.json') + +def load_settings(): + default_settings = { + "client_id": "DIN_CLIENT_ID_HER", + "tenant_id": "DIN_TENANT_ID_HER", + "temp_dir": "C:\\Temp_SP" + } + if not os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, 'w') as f: + json.dump(default_settings, f, indent=4) + return default_settings + + with open(SETTINGS_FILE, 'r') as f: + return json.load(f) + +settings = load_settings() +CLIENT_ID = settings.get("client_id") +TENANT_ID = settings.get("tenant_id") +AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" +SCOPES = ["Files.ReadWrite.All", "Sites.Read.All", "User.Read"] +TEMP_DIR = settings.get("temp_dir", "C:\\Temp_SP") + +if not os.path.exists(TEMP_DIR): + os.makedirs(TEMP_DIR) + +ctk.set_appearance_mode("System") +ctk.set_default_color_theme("blue") + +class SharePointApp(ctk.CTk): + def __init__(self): + super().__init__() + self.title("SharePoint Explorer") + self.geometry("1000x750") + + self.access_token = None + self.headers = {} + + # Navigation State + 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" + + # UI Layout - 3 Rækker: Top (Nav), Midt (Filer), Bund (Info) + self.grid_rowconfigure(1, weight=1) + self.grid_columnconfigure(0, weight=1) + + # 1. TOP NAVIGATION BAR + self.nav_frame = ctk.CTkFrame(self, height=60, corner_radius=0) + self.nav_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0) + + self.back_btn = ctk.CTkButton(self.nav_frame, text="← Tilbage", width=100, command=self.go_back, state="disabled") + self.back_btn.pack(side="left", padx=10, pady=10) + + self.home_btn = ctk.CTkButton(self.nav_frame, text="🏠 Hjem", width=100, command=self.load_sites, state="disabled") + self.home_btn.pack(side="left", padx=5, pady=10) + + self.login_btn = ctk.CTkButton(self.nav_frame, text="Log ind", width=120, fg_color="#28a745", hover_color="#218838", command=self.login) + self.login_btn.pack(side="right", padx=10, pady=10) + + # 2. MAIN CONTENT (File Area) + self.main_frame = ctk.CTkScrollableFrame(self, fg_color=("gray95", "gray10"), corner_radius=0) + self.main_frame.grid(row=1, column=0, sticky="nsew", padx=0, pady=0) + + # 3. FOOTER (Path & Status) + self.footer_frame = ctk.CTkFrame(self, height=40, corner_radius=0) + self.footer_frame.grid(row=2, column=0, sticky="ew") + + self.path_label = ctk.CTkLabel(self.footer_frame, text="Sti: /", font=ctk.CTkFont(size=12, weight="bold")) + self.path_label.pack(side="left", padx=15, pady=5) + + self.status_label = ctk.CTkLabel(self.footer_frame, text="Klar", text_color="gray") + self.status_label.pack(side="right", padx=15, pady=5) + + def set_status(self, text): + self.status_label.configure(text=text) + self.update_idletasks() + + def update_path_display(self): + path_str = " > ".join(self.current_path) + self.path_label.configure(text=f"📍 {path_str}") + + def login(self): + self.set_status("Logger ind...") + app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY) + accounts = app.get_accounts() + result = None + if accounts: + result = app.acquire_token_silent(SCOPES, account=accounts[0]) + if not result: + result = app.acquire_token_interactive(scopes=SCOPES) + + if "access_token" in result: + self.access_token = result["access_token"] + self.headers = {'Authorization': f'Bearer {self.access_token}'} + self.login_btn.configure(state="disabled", text="Logget ind", fg_color="gray") + self.home_btn.configure(state="normal") + self.load_sites() + else: + self.set_status("Login fejlede.") + messagebox.showerror("Login Error", result.get("error_description", "Unknown error")) + + def clear_main(self): + for widget in self.main_frame.winfo_children(): + widget.destroy() + self.back_btn.configure(state="normal" if self.history else "disabled") + self.update_path_display() + + def create_explorer_item(self, text, icon, command, is_file=False): + # En knap der ligner et række-element i Windows Explorer + frame = ctk.CTkFrame(self.main_frame, fg_color="transparent") + frame.pack(fill="x", padx=10, pady=1) + + btn = ctk.CTkButton( + frame, + text=f" {icon} {text}", + anchor="w", + height=35, + fg_color="transparent", + text_color=("gray10", "gray90"), + hover_color=("gray80", "gray25"), + font=ctk.CTkFont(size=13), + command=command + ) + btn.pack(fill="x") + return btn + + def load_sites(self): + self.set_status("Henter sites...") + self.clear_main() + self.current_path = ["SharePoint"] + self.history = [] + self.update_path_display() + + url = "https://graph.microsoft.com/v1.0/sites?search=*" + res = requests.get(url, headers=self.headers) + if res.status_code == 200: + sites = res.json().get('value', []) + # Sorter sites alfabetisk + sites.sort(key=lambda x: x.get('displayName', x.get('name', '')).lower()) + for site in sites: + name = site.get('displayName', site.get('name')) + self.create_explorer_item(name, "🌐", lambda s=site['id'], n=name: self.select_site(s, n)) + self.set_status(f"Fandt {len(sites)} sites.") + else: + self.set_status("Kunne ikke hente sites.") + + def select_site(self, site_id, name): + self.history.append(("SITES", None, "SharePoint")) + self.current_site_id = site_id + self.current_path.append(name) + self.load_drives(site_id) + + def load_drives(self, site_id): + self.set_status("Henter dokumentbiblioteker...") + self.clear_main() + url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives" + res = requests.get(url, headers=self.headers) + if res.status_code == 200: + drives = res.json().get('value', []) + # Sorter biblioteker alfabetisk + drives.sort(key=lambda x: x.get('name', '').lower()) + for drive in drives: + name = drive.get('name') + self.create_explorer_item(name, "📚", lambda d=drive['id'], n=name: self.select_drive(d, n)) + self.set_status("Vælg et bibliotek.") + else: + self.set_status("Fejl ved hentning af biblioteker.") + + def select_drive(self, drive_id, name): + self.history.append(("DRIVES", self.current_site_id, self.current_path[-1])) + self.current_drive_id = drive_id + self.current_path.append(name) + self.load_folder("root") + + def load_folder(self, folder_id): + self.set_status("Indlæser filer...") + self.clear_main() + + url = f"https://graph.microsoft.com/v1.0/drives/{self.current_drive_id}/root/children" if folder_id == "root" else \ + f"https://graph.microsoft.com/v1.0/drives/{self.current_drive_id}/items/{folder_id}/children" + + res = requests.get(url, headers=self.headers) + if res.status_code == 200: + items = res.json().get('value', []) + # Sorter: Mapper først, derefter filer. Begge alfabetisk. + items.sort(key=lambda x: (not 'folder' in x, x['name'].lower())) + + self.current_folder_id = folder_id + for item in items: + name, item_id, is_folder = item['name'], item['id'], 'folder' in item + icon = "📁" if is_folder else "📄" + cmd = lambda i=item_id, f=is_folder, n=name: self.item_clicked(i, f, n) + self.create_explorer_item(name, icon, cmd, not is_folder) + self.set_status("Klar") + else: + self.set_status(f"Fejl: {res.status_code}") + + def item_clicked(self, item_id, is_folder, name): + if is_folder: + self.history.append(("FOLDERS", self.current_drive_id, self.current_path[-1])) + self.current_path.append(name) + self.load_folder(item_id) + else: + threading.Thread(target=self.process_file, args=(item_id, name), daemon=True).start() + + def go_back(self): + if not self.history: return + mode, prev_id, path_segment = self.history.pop() + self.current_path.pop() + + if mode == "SITES": + self.load_sites() + elif mode == "DRIVES": + self.load_drives(prev_id) + elif mode == "FOLDERS": + # Her skal vi gemme folder_id i historikken for at gå tilbage korrekt + self.load_folder("root") # Forenklet: Går tilbage til rod + + def process_file(self, item_id, file_name): + base_url = f"https://graph.microsoft.com/v1.0/drives/{self.current_drive_id}/items/{item_id}" + + try: + # 1. Checkout + self.set_status(f"Låser '{file_name}'...") + requests.post(f"{base_url}/checkout", headers=self.headers) + + # 2. Download + self.set_status(f"Downloader '{file_name}'...") + res = requests.get(f"{base_url}/content", headers=self.headers) + ext = os.path.splitext(file_name)[1] + local_path = os.path.join(TEMP_DIR, f"{hashlib.md5(item_id.encode()).hexdigest()[:8]}{ext}") + + with open(local_path, 'wb') as f: + f.write(res.content) + + # 3. Åbn & Overvåg + self.set_status(f"Redigerer '{file_name}' - luk filen for at gemme...") + os.startfile(local_path) + time.sleep(3) + while True: + try: + os.rename(local_path, local_path) + break + except OSError: + time.sleep(2) + + # 4. Upload & Checkin + self.set_status(f"Uploader ændringer...") + with open(local_path, 'rb') as f: + requests.put(f"{base_url}/content", headers=self.headers, data=f) + + requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "Opdateret via SP Explorer"}) + os.remove(local_path) + self.set_status(f"'{file_name}' er gemt og tjekket ind.") + except Exception as e: + self.set_status(f"Fejl: {str(e)}") + messagebox.showerror("Fejl", f"Der skete en fejl under håndtering af filen:\n{e}") + +if __name__ == "__main__": + app = SharePointApp() + app.mainloop()