feat: implement multi-language support, file drag-and-drop, and new UI controls for file management
This commit is contained in:
@@ -23,29 +23,177 @@ def load_settings():
|
|||||||
default_settings = {
|
default_settings = {
|
||||||
"client_id": "DIN_CLIENT_ID_HER",
|
"client_id": "DIN_CLIENT_ID_HER",
|
||||||
"tenant_id": "DIN_TENANT_ID_HER",
|
"tenant_id": "DIN_TENANT_ID_HER",
|
||||||
"temp_dir": "C:\\Temp_SP"
|
"temp_dir": "C:\\Temp_SP",
|
||||||
|
"language": "da" # da eller en
|
||||||
}
|
}
|
||||||
if not os.path.exists(SETTINGS_FILE):
|
if not os.path.exists(SETTINGS_FILE):
|
||||||
with open(SETTINGS_FILE, 'w') as f:
|
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||||
json.dump(default_settings, f, indent=4)
|
json.dump(default_settings, f, indent=4)
|
||||||
return default_settings
|
return default_settings
|
||||||
|
|
||||||
with open(SETTINGS_FILE, 'r') as f:
|
with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
||||||
try:
|
try:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except:
|
except:
|
||||||
return default_settings
|
return default_settings
|
||||||
|
|
||||||
|
def save_settings(new_settings):
|
||||||
|
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(new_settings, f, indent=4)
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
CLIENT_ID = settings.get("client_id")
|
CLIENT_ID = settings.get("client_id")
|
||||||
TENANT_ID = settings.get("tenant_id")
|
TENANT_ID = settings.get("tenant_id")
|
||||||
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
||||||
SCOPES = ["Files.ReadWrite.All", "Sites.Read.All", "User.Read"]
|
SCOPES = ["Files.ReadWrite.All", "Sites.Read.All", "User.Read"]
|
||||||
TEMP_DIR = settings.get("temp_dir", "C:\\Temp_SP")
|
TEMP_DIR = settings.get("temp_dir", "C:\\Temp_SP")
|
||||||
|
CURRENT_LANG = settings.get("language", "da")
|
||||||
|
|
||||||
|
# --- TRANSLATIONS ---
|
||||||
|
STRINGS = {
|
||||||
|
"da": {
|
||||||
|
"title": "SharePoint Explorer",
|
||||||
|
"btn_back": "← Tilbage",
|
||||||
|
"btn_home": "🏠 Hjem",
|
||||||
|
"btn_save_changes": "💾 Gem ændringer i SharePoint",
|
||||||
|
"btn_login": "Log ind",
|
||||||
|
"btn_logged_in": "Logget ind",
|
||||||
|
"btn_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_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."
|
||||||
|
},
|
||||||
|
"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_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_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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if not os.path.exists(TEMP_DIR):
|
if not os.path.exists(TEMP_DIR):
|
||||||
os.makedirs(TEMP_DIR)
|
os.makedirs(TEMP_DIR)
|
||||||
|
|
||||||
|
class UploadDropTarget(wx.FileDropTarget):
|
||||||
|
def __init__(self, window, app):
|
||||||
|
wx.FileDropTarget.__init__(self)
|
||||||
|
self.window = window
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def OnDropFiles(self, x, y, filenames):
|
||||||
|
if not self.app.current_drive_id:
|
||||||
|
wx.MessageBox(self.app.get_txt("msg_drop_info"),
|
||||||
|
self.app.get_txt("msg_drop_title"), wx.OK | wx.ICON_INFORMATION)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for path in filenames:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
threading.Thread(target=self.app._upload_file_bg,
|
||||||
|
args=(path, self.app.current_drive_id, self.app.current_folder_id),
|
||||||
|
daemon=True).start()
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
threading.Thread(target=self.app._upload_folder_bg,
|
||||||
|
args=(path, self.app.current_drive_id, self.app.current_folder_id),
|
||||||
|
daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_file_hash(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for byte_block in iter(lambda: f.read(4096), b""):
|
||||||
|
sha256_hash.update(byte_block)
|
||||||
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
def format_size(bytes_num):
|
def format_size(bytes_num):
|
||||||
if bytes_num is None:
|
if bytes_num is None:
|
||||||
return ""
|
return ""
|
||||||
@@ -60,7 +208,8 @@ def format_size(bytes_num):
|
|||||||
|
|
||||||
class SharePointApp(wx.Frame):
|
class SharePointApp(wx.Frame):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(None, title="SharePoint Explorer", size=(1000, 750))
|
self.lang = CURRENT_LANG
|
||||||
|
super().__init__(None, title=self.get_txt("title"), size=(1000, 750))
|
||||||
|
|
||||||
# State
|
# State
|
||||||
self.access_token = None
|
self.access_token = None
|
||||||
@@ -91,6 +240,15 @@ class SharePointApp(wx.Frame):
|
|||||||
self.Show()
|
self.Show()
|
||||||
self.Bind(wx.EVT_CLOSE, self.on_close_window)
|
self.Bind(wx.EVT_CLOSE, self.on_close_window)
|
||||||
|
|
||||||
|
def get_txt(self, key, **kwargs):
|
||||||
|
text = STRINGS[self.lang].get(key, key)
|
||||||
|
if kwargs:
|
||||||
|
try:
|
||||||
|
return text.format(**kwargs)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return text
|
||||||
|
|
||||||
def InitUI(self):
|
def InitUI(self):
|
||||||
panel = wx.Panel(self)
|
panel = wx.Panel(self)
|
||||||
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
@@ -129,14 +287,36 @@ class SharePointApp(wx.Frame):
|
|||||||
self.done_btn.Bind(wx.EVT_BUTTON, self.on_done_editing_clicked)
|
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)
|
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="📤 Upload Fil", size=(120, 30))
|
||||||
|
self.upload_btn.Hide()
|
||||||
|
self.upload_btn.Bind(wx.EVT_BUTTON, self.on_upload_clicked)
|
||||||
|
nav_hbox.Add(self.upload_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||||||
|
|
||||||
|
self.upload_folder_btn = wx.Button(nav_panel, label="📁 Upload Mappe", size=(120, 30))
|
||||||
|
self.upload_folder_btn.Hide()
|
||||||
|
self.upload_folder_btn.Bind(wx.EVT_BUTTON, self.on_upload_folder_clicked)
|
||||||
|
nav_hbox.Add(self.upload_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||||||
|
|
||||||
|
self.new_folder_btn = wx.Button(nav_panel, label="➕ Ny Mappe", size=(100, 30))
|
||||||
|
self.new_folder_btn.Hide()
|
||||||
|
self.new_folder_btn.Bind(wx.EVT_BUTTON, self.on_new_folder_clicked)
|
||||||
|
nav_hbox.Add(self.new_folder_btn, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
|
||||||
|
|
||||||
nav_hbox.AddStretchSpacer(1)
|
nav_hbox.AddStretchSpacer(1)
|
||||||
|
|
||||||
self.login_btn = wx.Button(nav_panel, label="Log ind", size=(120, 30))
|
self.login_btn = wx.Button(nav_panel, label=self.get_txt("btn_login"), size=(120, 30))
|
||||||
self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn
|
self.login_btn.SetBackgroundColour(wx.Colour(40, 167, 69)) # Grøn
|
||||||
self.login_btn.SetForegroundColour(wx.WHITE)
|
self.login_btn.SetForegroundColour(wx.WHITE)
|
||||||
self.login_btn.Bind(wx.EVT_BUTTON, self.login)
|
self.login_btn.Bind(wx.EVT_BUTTON, self.login)
|
||||||
nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
|
nav_hbox.Add(self.login_btn, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||||||
|
|
||||||
|
# SPROG VÆLGER
|
||||||
|
self.lang_choice = wx.Choice(nav_panel, choices=["Dansk", "English"])
|
||||||
|
self.lang_choice.SetSelection(0 if self.lang == "da" else 1)
|
||||||
|
self.lang_choice.Bind(wx.EVT_CHOICE, self.on_language_changed)
|
||||||
|
nav_hbox.Add(self.lang_choice, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
|
||||||
|
|
||||||
nav_panel.SetSizer(nav_hbox)
|
nav_panel.SetSizer(nav_hbox)
|
||||||
vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5)
|
vbox.Add(nav_panel, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
|
||||||
@@ -168,6 +348,9 @@ class SharePointApp(wx.Frame):
|
|||||||
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
|
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
|
||||||
self.list_ctrl.Bind(wx.EVT_CONTEXT_MENU, self.on_right_click)
|
self.list_ctrl.Bind(wx.EVT_CONTEXT_MENU, self.on_right_click)
|
||||||
|
|
||||||
|
# AKTIVER DRAG & DROP
|
||||||
|
self.list_ctrl.SetDropTarget(UploadDropTarget(self.list_ctrl, self))
|
||||||
|
|
||||||
self.splitter.SplitVertically(self.tree_ctrl, self.list_ctrl, 250)
|
self.splitter.SplitVertically(self.tree_ctrl, self.list_ctrl, 250)
|
||||||
self.splitter.SetMinimumPaneSize(100)
|
self.splitter.SetMinimumPaneSize(100)
|
||||||
|
|
||||||
@@ -181,15 +364,187 @@ class SharePointApp(wx.Frame):
|
|||||||
self.Layout()
|
self.Layout()
|
||||||
|
|
||||||
def on_right_click(self, event):
|
def on_right_click(self, event):
|
||||||
item_idx = self.list_ctrl.GetFirstSelected()
|
selected_indices = []
|
||||||
if item_idx == -1: return
|
idx = self.list_ctrl.GetFirstSelected()
|
||||||
item = self.current_items[item_idx]
|
while idx != -1:
|
||||||
if item['type'] == "FILE":
|
selected_indices.append(idx)
|
||||||
menu = wx.Menu()
|
idx = self.list_ctrl.GetNextSelected(idx)
|
||||||
edit_item = menu.Append(wx.ID_ANY, "Rediger fil")
|
|
||||||
self.Bind(wx.EVT_MENU, lambda e: threading.Thread(target=self.process_file, args=(item['id'], item['name']), daemon=True).start(), edit_item)
|
menu = wx.Menu()
|
||||||
|
|
||||||
|
if selected_indices:
|
||||||
|
# Menu for de valgte emner
|
||||||
|
selected_items = [self.current_items[i] for i in selected_indices]
|
||||||
|
|
||||||
|
if len(selected_indices) == 1:
|
||||||
|
item = selected_items[0]
|
||||||
|
if item['type'] == "FILE":
|
||||||
|
edit_item = menu.Append(wx.ID_ANY, self.get_txt("msg_edit_file"))
|
||||||
|
self.Bind(wx.EVT_MENU, lambda e: threading.Thread(target=self.process_file, args=(item['id'], item['name']), daemon=True).start(), edit_item)
|
||||||
|
|
||||||
|
if item['type'] in ["FILE", "FOLDER"]:
|
||||||
|
rename_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_rename')} '{item['name']}'")
|
||||||
|
self.Bind(wx.EVT_MENU, lambda e: self.on_rename_clicked(item), rename_item)
|
||||||
|
|
||||||
|
delete_item = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} '{item['name']}'")
|
||||||
|
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_item)
|
||||||
|
else:
|
||||||
|
# Flere emner valgt
|
||||||
|
delete_items = menu.Append(wx.ID_ANY, f"{self.get_txt('msg_delete')} {len(selected_indices)} " + ("emner" if self.lang == "da" else "items"))
|
||||||
|
self.Bind(wx.EVT_MENU, lambda e: self.on_delete_items_clicked(selected_items), delete_items)
|
||||||
|
else:
|
||||||
|
# Menu for selve mappen (hvis man trykker på det tomme felt)
|
||||||
|
if self.current_drive_id:
|
||||||
|
upload_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_here"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_upload_clicked, upload_item)
|
||||||
|
|
||||||
|
upload_dir_item = menu.Append(wx.ID_ANY, self.get_txt("msg_upload_folder_here"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_upload_folder_clicked, upload_dir_item)
|
||||||
|
|
||||||
|
new_folder_item = menu.Append(wx.ID_ANY, self.get_txt("msg_new_folder_here"))
|
||||||
|
self.Bind(wx.EVT_MENU, self.on_new_folder_clicked, new_folder_item)
|
||||||
|
|
||||||
|
if menu.GetMenuItemCount() > 0:
|
||||||
self.PopupMenu(menu)
|
self.PopupMenu(menu)
|
||||||
menu.Destroy()
|
menu.Destroy()
|
||||||
|
|
||||||
|
# --- FILHÅNDTERING (Upload, Slet, Ny Mappe) ---
|
||||||
|
def on_delete_items_clicked(self, items):
|
||||||
|
if not items: return
|
||||||
|
|
||||||
|
names = ", ".join([f"'{i['name']}'" for i in items[:3]])
|
||||||
|
if len(items) > 3:
|
||||||
|
names += f" og {len(items)-3} andre..." if self.lang == "da" else f" and {len(items)-3} others..."
|
||||||
|
|
||||||
|
msg = self.get_txt("msg_confirm_delete_multi", num=len(items), names=names)
|
||||||
|
res = wx.MessageBox(msg, self.get_txt("msg_delete_title"), wx.YES_NO | wx.ICON_WARNING)
|
||||||
|
if res == wx.YES:
|
||||||
|
threading.Thread(target=self._delete_multiple_bg, args=(items,), daemon=True).start()
|
||||||
|
|
||||||
|
def _delete_multiple_bg(self, items):
|
||||||
|
self.lock_ui(True)
|
||||||
|
count = 0
|
||||||
|
total = len(items)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
status_text = "Sletter" if self.lang == "da" else "Deleting"
|
||||||
|
self.set_status(f"{status_text} {count+1}/{total}: '{item['name']}'...")
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}"
|
||||||
|
res = requests.delete(url, headers=self.headers)
|
||||||
|
if res.status_code in [204, 200]:
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
self.set_status(self.get_txt("msg_delete_failed", name=item['name']))
|
||||||
|
wx.CallAfter(wx.MessageBox, f"Error deleting '{item['name']}': {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
|
||||||
|
break
|
||||||
|
|
||||||
|
self._refresh_current_view()
|
||||||
|
self.lock_ui(False)
|
||||||
|
self.set_status(self.get_txt("msg_deleted_status", count=count, total=total))
|
||||||
|
|
||||||
|
def on_upload_clicked(self, event):
|
||||||
|
if not self.current_drive_id: return
|
||||||
|
with wx.FileDialog(self, self.get_txt("msg_select_file"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fd:
|
||||||
|
if fd.ShowModal() == wx.ID_OK:
|
||||||
|
path = fd.GetPath()
|
||||||
|
threading.Thread(target=self._upload_file_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start()
|
||||||
|
|
||||||
|
def _upload_file_bg(self, local_path, drive_id, parent_id):
|
||||||
|
filename = os.path.basename(local_path)
|
||||||
|
self.set_status(self.get_txt("msg_uploading", name=filename))
|
||||||
|
# Simpel upload (virker op til 4MB)
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content"
|
||||||
|
try:
|
||||||
|
with open(local_path, 'rb') as f:
|
||||||
|
res = requests.put(url, headers=self.headers, data=f)
|
||||||
|
if res.status_code in [200, 201]:
|
||||||
|
self.set_status(self.get_txt("msg_upload_success", name=filename))
|
||||||
|
self._refresh_current_view()
|
||||||
|
else:
|
||||||
|
self.set_status(self.get_txt("msg_upload_failed", name=filename, code=res.status_code))
|
||||||
|
wx.CallAfter(wx.MessageBox, self.get_txt("msg_upload_failed", name=filename, code=res.status_code), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
|
||||||
|
except Exception as e:
|
||||||
|
self.set_status(f"Upload error: {e}")
|
||||||
|
|
||||||
|
def on_upload_folder_clicked(self, event):
|
||||||
|
if not self.current_drive_id: return
|
||||||
|
with wx.DirDialog(self, self.get_txt("msg_select_folder"), style=wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST) as dd:
|
||||||
|
if dd.ShowModal() == wx.ID_OK:
|
||||||
|
path = dd.GetPath()
|
||||||
|
threading.Thread(target=self._upload_folder_bg, args=(path, self.current_drive_id, self.current_folder_id), daemon=True).start()
|
||||||
|
|
||||||
|
def _upload_folder_bg(self, local_dir, drive_id, parent_id):
|
||||||
|
dirname = os.path.basename(local_dir)
|
||||||
|
self.set_status(self.get_txt("msg_creating_folder", name=dirname))
|
||||||
|
|
||||||
|
# 1. Opret mappen på SharePoint
|
||||||
|
folder_id = self._create_folder_sync(dirname, drive_id, parent_id)
|
||||||
|
if not folder_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Upload filer i mappen
|
||||||
|
for item in os.listdir(local_dir):
|
||||||
|
full_path = os.path.join(local_dir, item)
|
||||||
|
if os.path.isfile(full_path):
|
||||||
|
self._upload_file_bg_sync(full_path, drive_id, folder_id)
|
||||||
|
elif os.path.isdir(full_path):
|
||||||
|
self._upload_folder_bg(full_path, drive_id, folder_id) # Rekursivt
|
||||||
|
|
||||||
|
self.set_status(self.get_txt("msg_folder_done", name=dirname))
|
||||||
|
self._refresh_current_view()
|
||||||
|
|
||||||
|
def _create_folder_sync(self, name, drive_id, parent_id):
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}/children"
|
||||||
|
body = {"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "rename"}
|
||||||
|
res = requests.post(url, headers=self.headers, json=body)
|
||||||
|
if res.status_code in [200, 201]:
|
||||||
|
return res.json().get('id')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _upload_file_bg_sync(self, local_path, drive_id, parent_id):
|
||||||
|
# Hjælper til sync upload brugt af mappe-upload
|
||||||
|
filename = os.path.basename(local_path)
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}:/{filename}:/content"
|
||||||
|
with open(local_path, 'rb') as f:
|
||||||
|
requests.put(url, headers=self.headers, data=f)
|
||||||
|
|
||||||
|
def on_new_folder_clicked(self, event):
|
||||||
|
if not self.current_drive_id: return
|
||||||
|
dlg = wx.TextEntryDialog(self, self.get_txt("msg_new_folder_prompt"), self.get_txt("msg_new_folder_title"))
|
||||||
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
|
name = dlg.GetValue()
|
||||||
|
if name:
|
||||||
|
threading.Thread(target=self._create_folder_bg, args=(name, self.current_drive_id, self.current_folder_id), daemon=True).start()
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
def _create_folder_bg(self, name, drive_id, parent_id):
|
||||||
|
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):
|
||||||
|
self.set_status(f"{self.get_txt('msg_rename')}...")
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/drives/{item['drive_id']}/items/{item['id']}"
|
||||||
|
body = {"name": new_name}
|
||||||
|
res = requests.patch(url, headers=self.headers, json=body)
|
||||||
|
if res.status_code in [200, 201]:
|
||||||
|
self.set_status(self.get_txt("msg_success"))
|
||||||
|
self._refresh_current_view()
|
||||||
|
else:
|
||||||
|
self.set_status(self.get_txt("msg_error"))
|
||||||
|
wx.CallAfter(wx.MessageBox, f"Rename failed: {res.status_code}", self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
|
||||||
|
|
||||||
def set_status(self, text):
|
def set_status(self, text):
|
||||||
wx.CallAfter(self.status_bar.SetStatusText, text)
|
wx.CallAfter(self.status_bar.SetStatusText, text)
|
||||||
@@ -210,9 +565,48 @@ class SharePointApp(wx.Frame):
|
|||||||
self.edit_wait_event.set()
|
self.edit_wait_event.set()
|
||||||
self.info_bar.Dismiss()
|
self.info_bar.Dismiss()
|
||||||
|
|
||||||
|
def on_language_changed(self, event):
|
||||||
|
selection = self.lang_choice.GetSelection()
|
||||||
|
self.lang = "da" if selection == 0 else "en"
|
||||||
|
|
||||||
|
# Gem til settings
|
||||||
|
settings["language"] = self.lang
|
||||||
|
save_settings(settings)
|
||||||
|
|
||||||
|
# Opdater UI tekster med det samme
|
||||||
|
self.SetTitle(self.get_txt("title"))
|
||||||
|
self.back_btn.SetLabel(self.get_txt("btn_back"))
|
||||||
|
self.home_btn.SetLabel(self.get_txt("btn_home"))
|
||||||
|
self.done_btn.SetLabel(self.get_txt("btn_save_changes"))
|
||||||
|
self.upload_btn.SetLabel(self.get_txt("btn_upload_file"))
|
||||||
|
self.upload_folder_btn.SetLabel(self.get_txt("btn_upload_folder"))
|
||||||
|
self.new_folder_btn.SetLabel(self.get_txt("btn_new_folder"))
|
||||||
|
|
||||||
|
if self.access_token:
|
||||||
|
self.login_btn.SetLabel(self.get_txt("btn_logged_in"))
|
||||||
|
else:
|
||||||
|
self.login_btn.SetLabel(self.get_txt("btn_login"))
|
||||||
|
|
||||||
|
# Opdater kolonner i ListCtrl
|
||||||
|
self.list_ctrl.SetColumnWidth(0, 450) # Refresh widths
|
||||||
|
item = self.list_ctrl.GetColumn(0)
|
||||||
|
self.list_ctrl.SetColumn(0, wx.ListItem()) # Reset column header logic could be complex in wx,
|
||||||
|
# but the simplest is to just re-insert columns or set text
|
||||||
|
|
||||||
|
# Re-set headers (Fix: explicitly set image to -1 to avoid icons in headers)
|
||||||
|
cols = [self.get_txt("col_name"), self.get_txt("col_type"), self.get_txt("col_size"), self.get_txt("col_modified")]
|
||||||
|
for i, text in enumerate(cols):
|
||||||
|
info = self.list_ctrl.GetColumn(i)
|
||||||
|
info.SetText(text)
|
||||||
|
info.SetImage(-1)
|
||||||
|
self.list_ctrl.SetColumn(i, info)
|
||||||
|
|
||||||
|
self.set_status(self.get_txt("status_ready"))
|
||||||
|
self._refresh_current_view() # Gendanner list-item tekster (Mappe/Fil)
|
||||||
|
|
||||||
def on_close_window(self, event):
|
def on_close_window(self, event):
|
||||||
if self.is_editing:
|
if self.is_editing:
|
||||||
self.show_info("Du er i gang med at redigere en fil. Luk din editor og gem ændringerne før du lukker programmet.", wx.ICON_WARNING, auto_hide=False)
|
self.show_info(self.get_txt("msg_edit_warning"), wx.ICON_WARNING, auto_hide=False)
|
||||||
return
|
return
|
||||||
event.Skip()
|
event.Skip()
|
||||||
|
|
||||||
@@ -284,7 +678,7 @@ class SharePointApp(wx.Frame):
|
|||||||
self.path_sizer.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
self.path_sizer.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||||
|
|
||||||
def login(self, event):
|
def login(self, event):
|
||||||
self.set_status("Logger ind...")
|
self.set_status(self.get_txt("status_logging_in"))
|
||||||
app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
|
app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
|
||||||
accounts = app.get_accounts()
|
accounts = app.get_accounts()
|
||||||
result = None
|
result = None
|
||||||
@@ -297,16 +691,16 @@ class SharePointApp(wx.Frame):
|
|||||||
self.access_token = result["access_token"]
|
self.access_token = result["access_token"]
|
||||||
self.headers = {'Authorization': f'Bearer {self.access_token}'}
|
self.headers = {'Authorization': f'Bearer {self.access_token}'}
|
||||||
self.login_btn.Disable()
|
self.login_btn.Disable()
|
||||||
self.login_btn.SetLabel("Logget ind")
|
self.login_btn.SetLabel(self.get_txt("btn_logged_in"))
|
||||||
self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå
|
self.login_btn.SetBackgroundColour(wx.Colour(200, 200, 200)) # Grå
|
||||||
self.home_btn.Enable()
|
self.home_btn.Enable()
|
||||||
self.load_sites()
|
self.load_sites()
|
||||||
else:
|
else:
|
||||||
self.set_status("Login fejlede.")
|
self.set_status(self.get_txt("status_login_failed"))
|
||||||
wx.MessageBox(result.get("error_description", "Unknown error"), "Login Error", wx.OK | wx.ICON_ERROR)
|
wx.MessageBox(result.get("error_description", "Unknown error"), self.get_txt("msg_error"), wx.OK | wx.ICON_ERROR)
|
||||||
|
|
||||||
def load_sites(self, event=None):
|
def load_sites(self, event=None):
|
||||||
self.set_status("Henter sites...")
|
self.set_status(self.get_txt("status_fetching_sites"))
|
||||||
self.tree_ctrl.DeleteAllItems()
|
self.tree_ctrl.DeleteAllItems()
|
||||||
self.list_ctrl.DeleteAllItems()
|
self.list_ctrl.DeleteAllItems()
|
||||||
self.current_items = []
|
self.current_items = []
|
||||||
@@ -331,7 +725,7 @@ class SharePointApp(wx.Frame):
|
|||||||
self.set_status("Kunne ikke hente sites.")
|
self.set_status("Kunne ikke hente sites.")
|
||||||
|
|
||||||
def _populate_sites_tree(self, sites):
|
def _populate_sites_tree(self, sites):
|
||||||
self.set_status(f"Fandt {len(sites)} sites.")
|
self.set_status(f"{len(sites)} sites.")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
name = site.get('displayName', site.get('name'))
|
name = site.get('displayName', site.get('name'))
|
||||||
node = self.tree_ctrl.AppendItem(self.tree_root, name, image=self.idx_site)
|
node = self.tree_ctrl.AppendItem(self.tree_root, name, image=self.idx_site)
|
||||||
@@ -347,7 +741,7 @@ class SharePointApp(wx.Frame):
|
|||||||
for i, site in enumerate(sites):
|
for i, site in enumerate(sites):
|
||||||
name = site.get('displayName', site.get('name'))
|
name = site.get('displayName', site.get('name'))
|
||||||
self.list_ctrl.InsertItem(i, name, self.idx_site)
|
self.list_ctrl.InsertItem(i, name, self.idx_site)
|
||||||
self.list_ctrl.SetItem(i, 1, "Site")
|
self.list_ctrl.SetItem(i, 1, self.get_txt("type_site"))
|
||||||
self.list_ctrl.SetItem(i, 2, "") # Størrelse
|
self.list_ctrl.SetItem(i, 2, "") # Størrelse
|
||||||
self.list_ctrl.SetItem(i, 3, "") # Sidst ændret
|
self.list_ctrl.SetItem(i, 3, "") # Sidst ændret
|
||||||
self.current_items.append({"type": "SITE", "id": site['id'], "name": name, "size": None, "modified": ""})
|
self.current_items.append({"type": "SITE", "id": site['id'], "name": name, "size": None, "modified": ""})
|
||||||
@@ -358,7 +752,7 @@ class SharePointApp(wx.Frame):
|
|||||||
if not data or data.get("loaded"):
|
if not data or data.get("loaded"):
|
||||||
return
|
return
|
||||||
|
|
||||||
loading_node = self.tree_ctrl.AppendItem(item, "Indlæser...")
|
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()
|
threading.Thread(target=self._fetch_tree_children_bg, args=(item, data), daemon=True).start()
|
||||||
|
|
||||||
def _fetch_tree_children_bg(self, parent_node, data):
|
def _fetch_tree_children_bg(self, parent_node, data):
|
||||||
@@ -429,25 +823,30 @@ class SharePointApp(wx.Frame):
|
|||||||
self.tree_ctrl.SelectItem(target_node)
|
self.tree_ctrl.SelectItem(target_node)
|
||||||
|
|
||||||
def on_tree_selected(self, event):
|
def on_tree_selected(self, event):
|
||||||
if self.is_editing: return
|
if not self or self.is_editing: return
|
||||||
item = event.GetItem()
|
item = event.GetItem()
|
||||||
data = self.tree_item_data.get(item)
|
data = self.tree_item_data.get(item)
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.current_path = data["path"]
|
try:
|
||||||
if self:
|
self.current_path = data["path"]
|
||||||
self.update_path_display()
|
self.update_path_display()
|
||||||
|
|
||||||
if not self.is_navigating_back:
|
if not self.is_navigating_back:
|
||||||
self.history.append(item)
|
self.history.append(item)
|
||||||
self.back_btn.Enable(len(self.history) > 1)
|
|
||||||
|
|
||||||
self.list_ctrl.DeleteAllItems()
|
# Check if button still exists
|
||||||
self.current_items = []
|
if self.back_btn:
|
||||||
self.set_status("Indlæser indhold...")
|
self.back_btn.Enable(len(self.history) > 1)
|
||||||
|
|
||||||
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
|
self.list_ctrl.DeleteAllItems()
|
||||||
|
self.current_items = []
|
||||||
|
self.set_status(self.get_txt("status_loading_content"))
|
||||||
|
|
||||||
|
threading.Thread(target=self._fetch_list_contents_bg, args=(data,), daemon=True).start()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _fetch_list_contents_bg(self, data):
|
def _fetch_list_contents_bg(self, data):
|
||||||
items_data = []
|
items_data = []
|
||||||
@@ -486,32 +885,50 @@ class SharePointApp(wx.Frame):
|
|||||||
wx.CallAfter(self._populate_list_ctrl, items_data, data)
|
wx.CallAfter(self._populate_list_ctrl, items_data, data)
|
||||||
|
|
||||||
def _populate_list_ctrl(self, items_data, parent_data):
|
def _populate_list_ctrl(self, items_data, parent_data):
|
||||||
self.list_ctrl.DeleteAllItems()
|
if not self: return
|
||||||
self.current_items = []
|
try:
|
||||||
for i, item in enumerate(items_data):
|
self.list_ctrl.DeleteAllItems()
|
||||||
img_idx = self.idx_file
|
self.current_items = []
|
||||||
if item['type'] == "FOLDER": img_idx = self.idx_folder
|
for i, item in enumerate(items_data):
|
||||||
elif item['type'] == "DRIVE": img_idx = self.idx_drive
|
img_idx = self.idx_file
|
||||||
elif item['type'] == "SITE": img_idx = self.idx_site
|
if item['type'] == "FOLDER": img_idx = self.idx_folder
|
||||||
|
elif item['type'] == "DRIVE": img_idx = self.idx_drive
|
||||||
|
elif item['type'] == "SITE": img_idx = self.idx_site
|
||||||
|
|
||||||
self.list_ctrl.InsertItem(i, item['name'], img_idx)
|
self.list_ctrl.InsertItem(i, item['name'], img_idx)
|
||||||
type_str = "Mappe" if item['type'] == "FOLDER" else "Fil" if item['type'] == "FILE" else "Bibliotek"
|
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)
|
self.list_ctrl.SetItem(i, 1, type_str)
|
||||||
size_str = format_size(item['size']) if item['size'] is not None else ""
|
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, 2, size_str)
|
||||||
self.list_ctrl.SetItem(i, 3, item['modified'])
|
self.list_ctrl.SetItem(i, 3, item['modified'])
|
||||||
self.current_items.append(item)
|
self.current_items.append(item)
|
||||||
|
|
||||||
self.set_status("Klar")
|
self.set_status(self.get_txt("status_ready"))
|
||||||
|
|
||||||
if parent_data['type'] == "SITE":
|
if parent_data['type'] == "SITE":
|
||||||
self.current_site_id = parent_data['id']
|
self.current_site_id = parent_data['id']
|
||||||
elif parent_data['type'] == "DRIVE":
|
elif parent_data['type'] == "DRIVE":
|
||||||
self.current_drive_id = parent_data['id']
|
self.current_drive_id = parent_data['id']
|
||||||
self.current_folder_id = "root"
|
self.current_folder_id = "root"
|
||||||
elif parent_data['type'] == "FOLDER":
|
elif parent_data['type'] == "FOLDER":
|
||||||
self.current_drive_id = parent_data['drive_id']
|
self.current_drive_id = parent_data['drive_id']
|
||||||
self.current_folder_id = parent_data['id']
|
self.current_folder_id = parent_data['id']
|
||||||
|
|
||||||
|
# Opdater knap-synlighed
|
||||||
|
can_upload = self.current_drive_id is not None
|
||||||
|
wx.CallAfter(lambda: self._safe_update_buttons(can_upload))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _safe_update_buttons(self, can_upload):
|
||||||
|
try:
|
||||||
|
if not self: return
|
||||||
|
self.upload_btn.Show(can_upload)
|
||||||
|
self.upload_folder_btn.Show(can_upload)
|
||||||
|
self.new_folder_btn.Show(can_upload)
|
||||||
|
self.Layout()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_item_activated(self, event):
|
def on_item_activated(self, event):
|
||||||
if self.is_editing: return
|
if self.is_editing: return
|
||||||
@@ -579,6 +996,9 @@ class SharePointApp(wx.Frame):
|
|||||||
with open(local_path, 'wb') as f:
|
with open(local_path, 'wb') as f:
|
||||||
f.write(res.content)
|
f.write(res.content)
|
||||||
|
|
||||||
|
# Beregn udgangspunkt hash
|
||||||
|
original_hash = get_file_hash(local_path)
|
||||||
|
|
||||||
# Checkout
|
# Checkout
|
||||||
requests.post(f"{base_url}/checkout", headers=self.headers)
|
requests.post(f"{base_url}/checkout", headers=self.headers)
|
||||||
|
|
||||||
@@ -614,16 +1034,21 @@ class SharePointApp(wx.Frame):
|
|||||||
wx.CallAfter(self.done_btn.Hide)
|
wx.CallAfter(self.done_btn.Hide)
|
||||||
wx.CallAfter(self.Layout)
|
wx.CallAfter(self.Layout)
|
||||||
|
|
||||||
# 4. Upload
|
# 4. Tjek om noget er ændret
|
||||||
self.set_status(f"Uploader ændringer...")
|
new_hash = get_file_hash(local_path)
|
||||||
with open(local_path, 'rb') as f:
|
if original_hash == new_hash:
|
||||||
upload_res = requests.put(f"{base_url}/content", headers=self.headers, data=f)
|
self.set_status("Ingen ændringer fundet. Springer upload over.")
|
||||||
if upload_res.status_code not in [200, 201]:
|
else:
|
||||||
raise Exception(f"Upload fejlede: {upload_res.status_code}")
|
# 5. Upload (kun hvis ændret)
|
||||||
|
self.set_status(f"Uploader ændringer...")
|
||||||
|
with open(local_path, 'rb') as f:
|
||||||
|
upload_res = requests.put(f"{base_url}/content", headers=self.headers, data=f)
|
||||||
|
if upload_res.status_code not in [200, 201]:
|
||||||
|
raise Exception(f"Upload fejlede: {upload_res.status_code}")
|
||||||
|
|
||||||
# 5. Checkin
|
# 6. Checkin (Uanset om ændret eller ej, for at frigive lås)
|
||||||
self.set_status(f"Tjekker '{file_name}' ind...")
|
self.set_status(f"Tjekker '{file_name}' ind...")
|
||||||
requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "Opdateret via SP Explorer"})
|
requests.post(f"{base_url}/checkin", headers=self.headers, json={"comment": "SP Explorer Edit"})
|
||||||
|
|
||||||
# Oprydning: Slet fil og derefter mappe
|
# Oprydning: Slet fil og derefter mappe
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user