From 03e0e580ac66106aad09beb4779f49d76f03d4a3 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 21:16:09 -0400 Subject: [PATCH 01/20] Add v0.2 filesystem safety foundation --- .gitignore | 3 +- documents_organizer/__init__.py | 3 + documents_organizer/filesystem.py | 68 ++++++++++++++++++++ documents_organizer/platform_utils.py | 26 ++++++++ main.py | 4 +- pyproject.toml | 25 ++++++++ requirements.txt | 5 +- tests/test_filesystem.py | 92 +++++++++++++++++++++++++++ 8 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 documents_organizer/__init__.py create mode 100644 documents_organizer/filesystem.py create mode 100644 documents_organizer/platform_utils.py create mode 100644 pyproject.toml create mode 100644 tests/test_filesystem.py diff --git a/.gitignore b/.gitignore index 7b2c3ac..429a05c 100644 --- a/.gitignore +++ b/.gitignore @@ -158,4 +158,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ +.pytest_tmp/ diff --git a/documents_organizer/__init__.py b/documents_organizer/__init__.py new file mode 100644 index 0000000..aa4b5ea --- /dev/null +++ b/documents_organizer/__init__.py @@ -0,0 +1,3 @@ +"""Documents Organizer application package.""" + +__version__ = "0.2.0" \ No newline at end of file diff --git a/documents_organizer/filesystem.py b/documents_organizer/filesystem.py new file mode 100644 index 0000000..6aade42 --- /dev/null +++ b/documents_organizer/filesystem.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + + +SYSTEM_FILES = { + ".DS_Store", + "Thumbs.db", +} + + +def get_unique_destination(destination: Path) -> Path: + """ + Return a destination path that does not overwrite an existing file. + + Example: + report.pdf + report (1).pdf + report (2).pdf + """ + if not destination.exists(): + return destination + + parent = destination.parent + stem = destination.stem + suffix = destination.suffix + + counter = 1 + + while True: + candidate = parent / f"{stem} ({counter}){suffix}" + + if not candidate.exists(): + return candidate + + counter += 1 + + +def move_file_safely(source: Path, destination: Path) -> Path: + """ + Move a file without silently overwriting an existing file. + + The destination directory is created automatically when necessary. + + Returns the final destination path. + """ + source = Path(source) + destination = Path(destination) + + if not source.exists(): + raise FileNotFoundError(f"Source file does not exist: {source}") + + if not source.is_file(): + raise ValueError(f"Source path is not a file: {source}") + + destination.parent.mkdir(parents=True, exist_ok=True) + + final_destination = get_unique_destination(destination) + + shutil.move(str(source), str(final_destination)) + + return final_destination + + +def should_ignore_file(path: Path) -> bool: + """Return True when a file should be ignored by organizer operations.""" + return path.name in SYSTEM_FILES \ No newline at end of file diff --git a/documents_organizer/platform_utils.py b/documents_organizer/platform_utils.py new file mode 100644 index 0000000..81af2e0 --- /dev/null +++ b/documents_organizer/platform_utils.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def open_in_file_manager(path: Path | str) -> None: + """Open a directory using the operating system's default file manager.""" + directory = Path(path).resolve() + + if not directory.exists(): + raise FileNotFoundError(f"Directory does not exist: {directory}") + + if not directory.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {directory}") + + if sys.platform == "win32": + os.startfile(str(directory)) + + elif sys.platform == "darwin": + subprocess.Popen(["open", str(directory)]) + + else: + subprocess.Popen(["xdg-open", str(directory)]) \ No newline at end of file diff --git a/main.py b/main.py index 9103b5f..a00aa69 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from tkinter import PhotoImage from tkinter import filedialog, messagebox, ttk from PIL import Image, ImageTk -from pystray import MenuItem as item +from pystray import MenuItem as Item # Define a global flag for canceling flattening operation cancel_flattening = False @@ -173,7 +173,7 @@ def hide_window(): # Create a system tray icon image = Image.open("images/folder-256.png") - menu = (item('Quit', exit_application), item('Show', show_window)) + menu = (Item('Quit', exit_application), Item('Show', show_window)) icon = pystray.Icon("DownloadOrganizer", image, "DownloadOrganizer", menu) # Run the application diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d62e795 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" + +[project] +name = "documents-organizer" +version = "0.2.0" +description = "A desktop utility for organizing and flattening file collections." +requires-python = ">=3.12" +dependencies = [ + "pillow==12.3.0", + "pystray==0.19.5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8,<10", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["documents_organizer*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3e9c556..061157e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -pillow==10.2.0 -pystray==0.19.5 \ No newline at end of file +pillow==12.3.0 +pystray==0.19.5 +pytest>=8.0,<10.0 \ No newline at end of file diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 0000000..c9a5075 --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,92 @@ +from pathlib import Path + +from documents_organizer.filesystem import ( + get_unique_destination, + move_file_safely, + should_ignore_file, +) + + +def test_unique_destination_returns_original_when_available(tmp_path: Path): + destination = tmp_path / "report.pdf" + + result = get_unique_destination(destination) + + assert result == destination + + +def test_unique_destination_adds_number_when_file_exists(tmp_path: Path): + original = tmp_path / "report.pdf" + original.write_text("original") + + result = get_unique_destination(original) + + assert result == tmp_path / "report (1).pdf" + + +def test_unique_destination_increments_until_available(tmp_path: Path): + (tmp_path / "report.pdf").write_text("original") + (tmp_path / "report (1).pdf").write_text("duplicate") + (tmp_path / "report (2).pdf").write_text("duplicate") + + result = get_unique_destination(tmp_path / "report.pdf") + + assert result == tmp_path / "report (3).pdf" + + +def test_move_file_safely_moves_file(tmp_path: Path): + source = tmp_path / "source" / "report.pdf" + source.parent.mkdir() + source.write_text("test file") + + destination = tmp_path / "destination" / "report.pdf" + + result = move_file_safely(source, destination) + + assert result == destination + assert destination.exists() + assert destination.read_text() == "test file" + assert not source.exists() + + +def test_move_file_safely_does_not_overwrite_existing_file(tmp_path: Path): + source = tmp_path / "source" / "report.pdf" + source.parent.mkdir() + source.write_text("new file") + + destination = tmp_path / "destination" / "report.pdf" + destination.parent.mkdir() + destination.write_text("existing file") + + result = move_file_safely(source, destination) + + assert result == tmp_path / "destination" / "report (1).pdf" + + assert destination.read_text() == "existing file" + assert result.read_text() == "new file" + + assert not source.exists() + + +def test_move_file_safely_rejects_missing_source(tmp_path: Path): + source = tmp_path / "missing.pdf" + destination = tmp_path / "destination.pdf" + + try: + move_file_safely(source, destination) + except FileNotFoundError: + pass + else: + raise AssertionError("Expected FileNotFoundError") + + +def test_should_ignore_ds_store(): + assert should_ignore_file(Path(".DS_Store")) + + +def test_should_ignore_thumbs_db(): + assert should_ignore_file(Path("Thumbs.db")) + + +def test_normal_file_is_not_ignored(): + assert not should_ignore_file(Path("report.pdf")) \ No newline at end of file From 8b70ff089a9c88a8ce50bd3b7417cb36da5bcc8d Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 21:53:50 -0400 Subject: [PATCH 02/20] Extract organizer service and add tests --- documents_organizer/app.py | 11 + documents_organizer/resources.py | 0 documents_organizer/services/__init__.py | 1 + documents_organizer/services/flattener.py | 0 documents_organizer/services/organizer.py | 249 +++++ documents_organizer/settings.py | 0 documents_organizer/ui/__init__.py | 0 documents_organizer/ui/dialogs.py | 0 documents_organizer/ui/main_window.py | 0 main.py | 1103 ++++++++++++++++----- tests/test_organizer.py | 321 ++++++ 11 files changed, 1443 insertions(+), 242 deletions(-) create mode 100644 documents_organizer/app.py create mode 100644 documents_organizer/resources.py create mode 100644 documents_organizer/services/__init__.py create mode 100644 documents_organizer/services/flattener.py create mode 100644 documents_organizer/services/organizer.py create mode 100644 documents_organizer/settings.py create mode 100644 documents_organizer/ui/__init__.py create mode 100644 documents_organizer/ui/dialogs.py create mode 100644 documents_organizer/ui/main_window.py create mode 100644 tests/test_organizer.py diff --git a/documents_organizer/app.py b/documents_organizer/app.py new file mode 100644 index 0000000..a0ba5a7 --- /dev/null +++ b/documents_organizer/app.py @@ -0,0 +1,11 @@ +import tkinter as tk + +from documents_organizer.ui.main_window import MainWindow + + +def run() -> None: + root = tk.Tk() + + MainWindow(root) + + root.mainloop() \ No newline at end of file diff --git a/documents_organizer/resources.py b/documents_organizer/resources.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/services/__init__.py b/documents_organizer/services/__init__.py new file mode 100644 index 0000000..7b58249 --- /dev/null +++ b/documents_organizer/services/__init__.py @@ -0,0 +1 @@ +"""Business services used by Documents Organizer.""" \ No newline at end of file diff --git a/documents_organizer/services/flattener.py b/documents_organizer/services/flattener.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/services/organizer.py b/documents_organizer/services/organizer.py new file mode 100644 index 0000000..361ae35 --- /dev/null +++ b/documents_organizer/services/organizer.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import datetime +import os +from dataclasses import dataclass, field +from pathlib import Path + +from documents_organizer.filesystem import ( + move_file_safely, + should_ignore_file, +) + + +@dataclass(frozen=True) +class OrganizationFailure: + """Represents a file or directory that could not be processed.""" + + path: Path + error: str + + +@dataclass +class OrganizationResult: + """Summary of a completed organization operation.""" + + moved: int = 0 + skipped: int = 0 + by_extension: dict[str, int] = field(default_factory=dict) + failures: list[OrganizationFailure] = field(default_factory=list) + + @property + def failed(self) -> int: + """Return the number of failed files or directories.""" + return len(self.failures) + + +def organize_directory(folder: Path | str) -> OrganizationResult: + """ + Organize files beneath a directory by extension and modification date. + + Files are first discovered before any files are moved. This prevents + directories created by the organization process from being discovered + and processed again during the same operation. + + Example: + + Downloads/ + report.pdf + + becomes: + + Downloads/ + pdf/ + 2026-08-25/ + report.pdf + """ + root = Path(folder).resolve() + + if not root.exists(): + raise FileNotFoundError( + f"Folder does not exist: {root}" + ) + + if not root.is_dir(): + raise NotADirectoryError( + f"Path is not a directory: {root}" + ) + + files, discovery_failures = _snapshot_files(root) + + result = OrganizationResult( + failures=discovery_failures, + ) + + for source in files: + _organize_file( + source, + result, + ) + + return result + + +def _snapshot_files( + root: Path, +) -> tuple[list[Path], list[OrganizationFailure]]: + """ + Capture the files that exist before organization begins. + + Taking a snapshot prevents newly-created extension/date directories + from being traversed by the current organization operation. + """ + files: list[Path] = [] + failures: list[OrganizationFailure] = [] + + def handle_walk_error(error: OSError) -> None: + error_path = Path( + error.filename + if error.filename + else root + ) + + failures.append( + OrganizationFailure( + path=error_path, + error=str(error), + ) + ) + + for current_root, directories, filenames in os.walk( + root, + onerror=handle_walk_error, + followlinks=False, + ): + current_path = Path(current_root) + + for filename in filenames: + files.append( + current_path / filename + ) + + return files, failures + + +def _organize_file( + source: Path, + result: OrganizationResult, +) -> None: + """Organize one file and update the operation result.""" + try: + if not source.exists(): + raise FileNotFoundError( + f"File no longer exists: {source}" + ) + + if not source.is_file(): + result.skipped += 1 + return + + if should_ignore_file(source): + result.skipped += 1 + return + + if is_already_organized(source): + result.skipped += 1 + return + + extension_name = get_extension_name(source) + + modified_date = get_modified_date( + source + ) + + destination = ( + source.parent + / extension_name + / modified_date + / source.name + ) + + move_file_safely( + source, + destination, + ) + + result.moved += 1 + + result.by_extension[extension_name] = ( + result.by_extension.get( + extension_name, + 0, + ) + + 1 + ) + + except ( + FileNotFoundError, + PermissionError, + OSError, + ValueError, + ) as exc: + result.failures.append( + OrganizationFailure( + path=source, + error=str(exc), + ) + ) + + +def get_extension_name(path: Path) -> str: + """ + Return the directory name used for a file extension. + + Files without an extension are placed in the 'other' directory. + """ + suffix = path.suffix.lower() + + if not suffix: + return "other" + + return suffix.lstrip(".") + + +def get_modified_date(path: Path) -> str: + """Return the file modification date in YYYY-MM-DD format.""" + modified_timestamp = path.stat().st_mtime + + return datetime.datetime.fromtimestamp( + modified_timestamp + ).strftime("%Y-%m-%d") + + +def is_already_organized(path: Path) -> bool: + """ + Return True when a file already appears to be in an organized location. + + Expected structure: + + // + + Example: + + pdf/2026-08-25/report.pdf + """ + date_directory = path.parent + + extension_directory = date_directory.parent + + expected_extension = get_extension_name( + path + ) + + if ( + extension_directory.name.lower() + != expected_extension.lower() + ): + return False + + try: + parsed_date = datetime.date.fromisoformat( + date_directory.name + ) + except ValueError: + return False + + return ( + parsed_date.isoformat() + == date_directory.name + ) \ No newline at end of file diff --git a/documents_organizer/settings.py b/documents_organizer/settings.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/__init__.py b/documents_organizer/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/dialogs.py b/documents_organizer/ui/dialogs.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index a00aa69..3bd8427 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,22 @@ import os import shutil -import datetime -import pystray import threading import tkinter as tk import tkinter.scrolledtext as scrolledtext -from tkinter import PhotoImage -from tkinter import filedialog, messagebox, ttk -from PIL import Image, ImageTk -from pystray import MenuItem as Item +from tkinter import filedialog, messagebox, simpledialog, ttk + +import pystray +from PIL import Image +from pystray import MenuItem as item + +from documents_organizer import __version__ +from documents_organizer.filesystem import move_file_safely +from documents_organizer.platform_utils import open_in_file_manager +from documents_organizer.services.organizer import ( + OrganizationResult, + organize_directory, +) + # Define a global flag for canceling flattening operation cancel_flattening = False @@ -16,351 +24,962 @@ # Global variable to store the folder path folder_path = "" -# Global dictionary to track files organized by extension -organized_files = {} +def organize_files(selected_folder): + """Start an organization operation in a background thread.""" + log_to_text( + f"Organizing: {selected_folder}" + ) + + worker = threading.Thread( + target=_run_organizer, + args=(selected_folder,), + daemon=True, + ) + + worker.start() + + +def _run_organizer(selected_folder): + """Run the organizer service outside the Tkinter main thread.""" + try: + result = organize_directory( + selected_folder + ) + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + win.after( + 0, + _handle_organization_error, + str(exc), + ) + + return + + win.after( + 0, + _handle_organization_result, + result, + ) + + +def _handle_organization_result( + result: OrganizationResult, +): + """Display organization results on the Tkinter main thread.""" + for extension, count in sorted( + result.by_extension.items() + ): + label = ( + "file" + if count == 1 + else "files" + ) + + log_to_text( + f"Organized {count} " + f"{extension} {label}." + ) + + if result.skipped: + label = ( + "file" + if result.skipped == 1 + else "files" + ) + + log_to_text( + f"Skipped {result.skipped} " + f"{label}." + ) + + if result.failed: + label = ( + "file" + if result.failed == 1 + else "files" + ) + + log_to_text( + f"Unable to process " + f"{result.failed} {label}." + ) + + for failure in result.failures: + log_to_text( + f" {failure.path}: " + f"{failure.error}" + ) + + log_to_text( + f"Organization complete. " + f"{result.moved} files moved." + ) + + refresh_treeview() + + +def _handle_organization_error( + message: str, +): + """Display a fatal organization error.""" + log_to_text( + f"Organization failed: {message}" + ) + + messagebox.showerror( + "Organization Failed", + message, + ) -# Function to organize files and folders by extension and date modified -def organize_files(folder_path): - """Organize files and folders by extension and date modified.""" - log_to_text("Organizing files...") - threading.Thread(target=organize_folder, args=(folder_path,)).start() -def organize_folder(folder): - """Organize files in the specified folder.""" - for root, dirs, files in os.walk(folder): - # Organize files - for filename in files: - if filename not in ['.DS_Store', 'Thumbs.db']: # Exclude system files - src = os.path.join(root, filename) - organize_file(src) - - # Log message after organizing files of each extension - for extension, files in organized_files.items(): - log_to_text(f"Organized {len(files)} {extension} files") - -def organize_file(src): - """Organize a single file based on its extension and date modified.""" - extension = os.path.splitext(src)[1].lower() - modified_time = os.path.getmtime(src) - modified_date = datetime.datetime.fromtimestamp(modified_time).strftime('%Y-%m-%d') - - # Create extension folder and modified date folder within the parent directory - parent_dir = os.path.dirname(src) - extension_folder = os.path.join(parent_dir, extension[1:]) - os.makedirs(extension_folder, exist_ok=True) - date_folder = os.path.join(extension_folder, modified_date) - os.makedirs(date_folder, exist_ok=True) - - # Move the file to the organized folder - dst = os.path.join(date_folder, os.path.basename(src)) - shutil.move(src, dst) - - # Update organized_files dictionary - if extension in organized_files: - organized_files[extension].append(dst) - else: - organized_files[extension] = [dst] - - -# Function to flatten folders def flatten_folders(): """Flatten folders based on specified extensions.""" selected_item = tree.focus() + if not selected_item: - messagebox.showerror("Error", "Please select a folder first.") + messagebox.showerror( + "Error", + "Please select a folder first.", + ) return - folder_path = get_full_path(tree, selected_item) - if not folder_path: - messagebox.showerror("Error", "Unable to determine folder path.") + selected_folder = get_full_path( + tree, + selected_item, + ) + + if not selected_folder: + messagebox.showerror( + "Error", + "Unable to determine folder path.", + ) return - + global cancel_flattening - cancel_flattening = False # Reset the flag before starting flattening operation - threading.Thread(target=flatten_folder_recursive, args=(folder_path,)).start() + cancel_flattening = False + + threading.Thread( + target=flatten_folder_recursive, + args=(selected_folder,), + daemon=True, + ).start() + def flatten_folder_recursive(folder): """Recursively flatten folders.""" global cancel_flattening + if cancel_flattening: log_to_text("Flattening operation canceled.") return - - for root, dirs, files in os.walk(folder): - for dir in dirs[:]: - dir_path = os.path.join(root, dir) - if os.path.basename(dir).lower() in extensions_to_flatten: - flatten_subfolders(dir_path) # Flatten the extension-named folder - move_files_to_parent(dir_path) # Move files to the parent folder - dirs.remove(dir) # Remove the extension-named folder from further traversal - else: - if not cancel_flattening: - flatten_folder_recursive(dir_path) # Continue traversing non-extension-named folders - - # Log when a primary folder is clean of extension folders - if all(os.path.basename(dir).lower() not in extensions_to_flatten for dir in os.listdir(folder)): - log_to_text(f"{os.path.basename(folder)} is clean of extension folders.") - refresh_treeview() - - # Log flattening completion - log_to_text(f"All subfolders in {os.path.basename(folder)} flattened.") + + try: + for root, dirs, files in os.walk(folder): + if cancel_flattening: + log_to_text( + "Flattening operation canceled." + ) + return + + for directory in dirs[:]: + if cancel_flattening: + log_to_text( + "Flattening operation canceled." + ) + return + + dir_path = os.path.join( + root, + directory, + ) + + if ( + os.path.basename(directory).lower() + in extensions_to_flatten + ): + flatten_subfolders(dir_path) + + if cancel_flattening: + log_to_text( + "Flattening operation canceled." + ) + return + + move_files_to_parent(dir_path) + + dirs.remove(directory) + + else: + if not cancel_flattening: + flatten_folder_recursive( + dir_path + ) + + if not os.path.isdir(folder): + return + + remaining_items = os.listdir(folder) + + if all( + item_name.lower() + not in extensions_to_flatten + for item_name in remaining_items + ): + log_to_text( + f"{os.path.basename(folder)} " + "is clean of extension folders." + ) + + refresh_treeview() + + log_to_text( + f"All subfolders in " + f"{os.path.basename(folder)} flattened." + ) + + except Exception as exc: + log_to_text( + f"Error flattening folders: {exc}" + ) + def flatten_subfolders(folder): """Flatten subfolders of the specified folder.""" + global cancel_flattening + for root, dirs, files in os.walk(folder): - for file in files: - src = os.path.join(root, file) - dst = os.path.join(folder, file) - shutil.move(src, dst) + if cancel_flattening: + return + + for filename in files: + if cancel_flattening: + return + + src = os.path.join( + root, + filename, + ) + + destination = os.path.join( + folder, + filename, + ) + + # A file already located directly in the target folder + # does not need to be moved. + if os.path.abspath(src) == os.path.abspath( + destination + ): + continue + + move_file_safely( + src, + destination, + ) + + for root, dirs, files in os.walk( + folder, + topdown=False, + ): + if cancel_flattening: + return + + for directory in dirs: + directory_path = os.path.join( + root, + directory, + ) + + if os.path.isdir(directory_path): + shutil.rmtree(directory_path) - # Delete all subfolders - for root, dirs, files in os.walk(folder, topdown=False): - for dir in dirs: - shutil.rmtree(os.path.join(root, dir)) def move_files_to_parent(folder): """Move files from a folder to its parent folder.""" + global cancel_flattening + + parent_folder = os.path.dirname(folder) + for root, dirs, files in os.walk(folder): - for file in files: - src = os.path.join(root, file) - dst = os.path.join(os.path.dirname(folder), file) - shutil.move(src, dst) - - # Delete the extension-named folder - shutil.rmtree(folder) - -# Function to get the full path of a selected item in the Treeview + if cancel_flattening: + return + + for filename in files: + if cancel_flattening: + return + + src = os.path.join( + root, + filename, + ) + + destination = os.path.join( + parent_folder, + filename, + ) + + move_file_safely( + src, + destination, + ) + + if os.path.isdir(folder): + shutil.rmtree(folder) + + def get_full_path(tree, item): """Get the full path of a selected item in the Treeview.""" - path_components = [tree.item(item)['text']] + path_components = [ + tree.item(item)["text"] + ] + parent = tree.parent(item) + while parent: - path_components.insert(0, tree.item(parent)['text']) + path_components.insert( + 0, + tree.item(parent)["text"], + ) + parent = tree.parent(parent) - return os.path.join(*path_components) -# Function to add extensions to flatten + return os.path.join( + *path_components + ) + + def add_extensions(): """Add extensions to the list of extensions to flatten.""" existing_extensions = extensions_to_flatten - new_extensions = input_extensions(existing_extensions) - extensions_to_flatten.extend(new_extensions) - log_to_text("Extensions to flatten:\n" + ', '.join(extensions_to_flatten)) + + new_extensions = input_extensions( + existing_extensions + ) + + extensions_to_flatten.extend( + new_extensions + ) + + log_to_text( + "Extensions to flatten:\n" + + ", ".join(extensions_to_flatten) + ) + def input_extensions(existing_extensions): """Prompt user to input extensions to add.""" - extensions_str = tk.simpledialog.askstring("Add Extensions", "Enter extensions separated by commas (e.g., mp4, webp, exe, jpg): ") - if extensions_str: - new_extensions = [ext.strip() for ext in extensions_str.split(",")] - return list(set(new_extensions) - set(existing_extensions)) - return [] + extensions_str = simpledialog.askstring( + "Add Extensions", + ( + "Enter extensions separated by commas " + "(e.g., mp4, webp, exe, jpg):" + ), + ) + + if not extensions_str: + return [] + + new_extensions = [ + extension.strip().lower().lstrip(".") + for extension in extensions_str.split(",") + if extension.strip() + ] + + return list( + set(new_extensions) + - set(existing_extensions) + ) + def stop_flattening(): """Stop the flattening operation.""" global cancel_flattening + cancel_flattening = True -# Function to exit the application -def exit_application(icon, item): - """Exit the application.""" + log_to_text( + "Cancel requested..." + ) + + +def exit_application(icon, menu_item): + """Exit the application from the system tray.""" icon.stop() - win.destroy() -# Function to hide the window + win.after( + 0, + win.destroy, + ) + + def hide_window(): """Hide the window and display a system tray icon.""" win.withdraw() - # Create a system tray icon - image = Image.open("images/folder-256.png") - menu = (Item('Quit', exit_application), Item('Show', show_window)) - icon = pystray.Icon("DownloadOrganizer", image, "DownloadOrganizer", menu) - - # Run the application - icon.run() - -# Function to show the window again -def show_window(icon, item): - """Show the window again.""" + try: + image = Image.open( + "images/folder-256.png" + ) + + menu = ( + item( + "Show", + show_window, + ), + item( + "Quit", + exit_application, + ), + ) + + icon = pystray.Icon( + "DocumentsOrganizer", + image, + "Documents Organizer", + menu, + ) + + threading.Thread( + target=icon.run, + daemon=True, + ).start() + + except Exception as exc: + win.deiconify() + + messagebox.showerror( + "System Tray Error", + ( + "Documents Organizer could not " + f"start the system tray icon.\n\n{exc}" + ), + ) + + +def show_window(icon, menu_item): + """Show the application window again.""" icon.stop() - win.after(0, win.deiconify()) -# Function to handle "Select Folder" menu option + win.after( + 0, + win.deiconify, + ) + + def select_folder(): - """Handle the 'Select Folder' menu option.""" + """Handle the Select Folder menu option.""" global folder_path - folder_path = filedialog.askdirectory() - if folder_path: - update_treeview(folder_path) -# Function to handle "Run" menu option + selected_folder = filedialog.askdirectory() + + if not selected_folder: + return + + folder_path = selected_folder + + update_treeview( + folder_path + ) + + def run_organizer(): - """Handle the 'Run' menu option.""" + """Handle the Organize Folders menu option.""" selected_item = tree.focus() + if not selected_item: - messagebox.showerror("Error", "Please select a folder first.") + messagebox.showerror( + "Error", + "Please select a folder first.", + ) + return + + selected_folder = get_full_path( + tree, + selected_item, + ) + + if not os.path.isdir( + selected_folder + ): + messagebox.showerror( + "Error", + "The selected folder does not exist.", + ) return - - global folder_path - folder_path = tree.item(selected_item)['text'] - organize_files(folder_path) -# Function to handle "Exit" menu option + organize_files( + selected_folder + ) + + def exit_app(): - """Handle the 'Exit' menu option.""" - root.quit() + """Exit the application.""" + win.destroy() + -# Function to update the Treeview with directory structure def update_treeview(directory): """Update the Treeview with the directory structure.""" - tree.delete(*tree.get_children()) - populate_tree(tree, directory) + tree.delete( + *tree.get_children() + ) + + populate_tree( + tree, + directory, + ) + def populate_tree(tree, directory): """Populate the Treeview with the directory structure.""" - root_node = tree.insert('', 'end', text=directory) - populate_children(tree, root_node, directory) + root_node = tree.insert( + "", + "end", + text=directory, + ) + + populate_children( + tree, + root_node, + directory, + ) + def populate_children(tree, parent, directory): """Populate children of a node in the Treeview.""" - for item in os.listdir(directory): - item_path = os.path.join(directory, item) - if os.path.isdir(item_path): - node = tree.insert(parent, 'end', text=item) - populate_subdirectories(tree, node, item_path) + try: + items = os.listdir(directory) + except (PermissionError, FileNotFoundError): + return + + for item_name in items: + item_path = os.path.join( + directory, + item_name, + ) -def populate_subdirectories(tree, parent, directory): + if os.path.isdir(item_path): + node = tree.insert( + parent, + "end", + text=item_name, + ) + + populate_subdirectories( + tree, + node, + item_path, + ) + + +def populate_subdirectories( + tree, + parent, + directory, +): """Populate subdirectories of a node in the Treeview.""" - for item in os.listdir(directory): - item_path = os.path.join(directory, item) + try: + items = os.listdir(directory) + except (PermissionError, FileNotFoundError): + return + + for item_name in items: + item_path = os.path.join( + directory, + item_name, + ) + if os.path.isdir(item_path): - node = tree.insert(parent, 'end', text=item) - populate_subdirectories(tree, node, item_path) + node = tree.insert( + parent, + "end", + text=item_name, + ) + + populate_subdirectories( + tree, + node, + item_path, + ) + -# Function to refresh the Treeview after folder operations def refresh_treeview(): """Refresh the Treeview after folder operations.""" global folder_path - tree.delete(*tree.get_children()) # Clear the Treeview - update_treeview(folder_path) -# Function to ensure the latest log entry is always visible + if not folder_path: + return + + if not os.path.isdir(folder_path): + return + + tree.delete( + *tree.get_children() + ) + + update_treeview( + folder_path + ) + + def scroll_to_end(): """Scroll to the end of the log.""" - log_text.see(tk.END) - + log_text.see( + tk.END + ) + + def start_application(): - # Display all extensions to flatten in log_text - log_to_text("Extensions to flatten:\n" + ', '.join(extensions_to_flatten)) + """Display application startup information.""" + log_to_text( + "Extensions to flatten:\n" + + ", ".join(extensions_to_flatten) + ) + -# Add log_text modification to ensure latest entry is visible def log_to_text(message): """Log a message to the text widget.""" - log_text.config(state=tk.NORMAL) - log_text.insert(tk.END, message + "\n") - log_text.config(state=tk.DISABLED) + log_text.config( + state=tk.NORMAL + ) + + log_text.insert( + tk.END, + message + "\n", + ) + + log_text.config( + state=tk.DISABLED + ) + scroll_to_end() -# Function to clear log + def clear_log(): """Clear the log.""" - log_text.config(state=tk.NORMAL) - log_text.delete('1.0', tk.END) - log_text.config(state=tk.DISABLED) + log_text.config( + state=tk.NORMAL + ) + + log_text.delete( + "1.0", + tk.END, + ) + + log_text.config( + state=tk.DISABLED + ) + start_application() - -def open_explorer_folder(): - selected_item = tree.selection()[0] - folder_path = get_full_path(tree, selected_item) - os.startfile(folder_path) - + + +def open_selected_folder(): + """Open the selected folder in the operating system's file manager.""" + selected_items = tree.selection() + + if not selected_items: + messagebox.showerror( + "Error", + "Please select a folder first.", + ) + return + + selected_item = selected_items[0] + + selected_path = get_full_path( + tree, + selected_item, + ) + + try: + open_in_file_manager( + selected_path + ) + + except ( + FileNotFoundError, + NotADirectoryError, + OSError, + ) as exc: + messagebox.showerror( + "Unable to Open Folder", + str(exc), + ) + + def popup_menu(event): - # Get the item that was clicked on - item = tree.identify_row(event.y) - tree.selection_set(item) - - # Create the popup menu - popup = tk.Menu(win, tearoff=0) - popup.add_command(label="Reveal in Explorer", command=open_explorer_folder) - - # Display the popup menu at the location of the click - popup.post(event.x_root, event.y_root) - -# Function to display information about the application -def show_about(): - messagebox.showinfo("About", "Document Organizer\nVersion: v0.1\nPython Version: v3.12.0\nCreated by: David Southwood\nLicense: MIT License") + """Display the folder context menu.""" + selected_item = tree.identify_row( + event.y + ) + if not selected_item: + return -# Extensions to be flattened -extensions_to_flatten = ['ini', 'zip', 'mp4', 'pdf', 'cpp', 'rar', 'jpg', 'save', 'h', 'txt', 'doc', 'bin', 'exe', 'jar', 'png', 'tmp', 'docx', 'webp', 'mm'] # Add more as needed + tree.selection_set( + selected_item + ) -# Create an instance of tkinter frame or window -win = tk.Tk() + tree.focus( + selected_item + ) -win.title("Documents Organizer") -win.iconbitmap("images/folder-256.ico") -# Set the size of the window -win.geometry("1080x800") + popup = tk.Menu( + win, + tearoff=0, + ) -# Create menu bar -menu_bar = tk.Menu(win) -win.config(menu=menu_bar) + popup.add_command( + label="Open in File Manager", + command=open_selected_folder, + ) -# Create "File" menu -file_menu = tk.Menu(menu_bar, tearoff=0) -file_menu.add_command(label="Select Folder", command=select_folder) -file_menu.add_separator() -file_menu.add_command(label="Exit", command=exit_app) -menu_bar.add_cascade(label="File", menu=file_menu) + popup.post( + event.x_root, + event.y_root, + ) -# Create "Action" menu -action_menu = tk.Menu(menu_bar, tearoff=0) -# Organize submenu -organize_submenu = tk.Menu(action_menu, tearoff=0) -organize_submenu.add_command(label="Organize Folders", command=run_organizer) -organize_submenu.add_command(label="Flatten Folders", command=flatten_folders) -organize_submenu.add_command(label="Cancel Flatten Folders", command=stop_flattening) -action_menu.add_cascade(label="Organize", menu=organize_submenu) +def show_about(): + """Display information about the application.""" + messagebox.showinfo( + "About", + ( + "Documents Organizer\n" + f"Version: v{__version__}\n" + "Created by: David Southwood\n" + "License: MIT License" + ), + ) + + +extensions_to_flatten = [ + "ini", + "zip", + "mp4", + "pdf", + "cpp", + "rar", + "jpg", + "save", + "h", + "txt", + "doc", + "bin", + "exe", + "jar", + "png", + "tmp", + "docx", + "webp", + "mm", +] + -# Extensions submenu -action_menu.add_command(label="Add Extensions", command=add_extensions) +win = tk.Tk() -# View submenu -view_submenu = tk.Menu(action_menu, tearoff=0) -view_submenu.add_command(label="Clear Log", command=clear_log) -view_submenu.add_command(label="Refresh TreeView", command=refresh_treeview) -action_menu.add_cascade(label="View", menu=view_submenu) +win.title( + "Documents Organizer" +) -menu_bar.add_cascade(label="Action", menu=action_menu) +try: + win.iconbitmap( + "images/folder-256.ico" + ) +except tk.TclError: + pass -# Create "Help" menu -help_menu = tk.Menu(menu_bar, tearoff=0) -help_menu.add_command(label="About", command=show_about) -menu_bar.add_cascade(label="Help", menu=help_menu) +win.geometry( + "1080x800" +) -# Create and configure Treeview widget -tree_frame = tk.Frame(win) -tree_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) -# Make the Treeview expand to fill the entire frame -tree = ttk.Treeview(tree_frame) -tree.pack(expand=tk.YES, fill=tk.BOTH, padx=5, pady=5) +# Menu bar +menu_bar = tk.Menu( + win +) -# Bind the right-click event to the popup_menu function -tree.bind("", popup_menu) +win.config( + menu=menu_bar +) -# Add a Sizegrip for resizing -ttk.Sizegrip(tree_frame).pack(side="right", fill="y") -# Create and configure ScrolledText widget to display logs -log_frame = tk.Frame(win, width=500) -log_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) +# File menu +file_menu = tk.Menu( + menu_bar, + tearoff=0, +) -log_text = scrolledtext.ScrolledText(log_frame, height=10, width=50) -log_text.pack(expand=tk.YES, fill=tk.BOTH) +file_menu.add_command( + label="Select Folder", + command=select_folder, +) + +file_menu.add_separator() + +file_menu.add_command( + label="Exit", + command=exit_app, +) + +menu_bar.add_cascade( + label="File", + menu=file_menu, +) + + +# Action menu +action_menu = tk.Menu( + menu_bar, + tearoff=0, +) + +organize_submenu = tk.Menu( + action_menu, + tearoff=0, +) + +organize_submenu.add_command( + label="Organize Folders", + command=run_organizer, +) + +organize_submenu.add_command( + label="Flatten Folders", + command=flatten_folders, +) + +organize_submenu.add_command( + label="Cancel Flatten Folders", + command=stop_flattening, +) + +action_menu.add_cascade( + label="Organize", + menu=organize_submenu, +) + +action_menu.add_command( + label="Add Extensions", + command=add_extensions, +) + +view_submenu = tk.Menu( + action_menu, + tearoff=0, +) + +view_submenu.add_command( + label="Clear Log", + command=clear_log, +) + +view_submenu.add_command( + label="Refresh TreeView", + command=refresh_treeview, +) + +action_menu.add_cascade( + label="View", + menu=view_submenu, +) + +menu_bar.add_cascade( + label="Action", + menu=action_menu, +) + + +# Help menu +help_menu = tk.Menu( + menu_bar, + tearoff=0, +) + +help_menu.add_command( + label="About", + command=show_about, +) + +menu_bar.add_cascade( + label="Help", + menu=help_menu, +) + + +# Treeview +tree_frame = tk.Frame( + win +) + +tree_frame.pack( + side=tk.LEFT, + fill=tk.BOTH, + expand=True, +) + +tree = ttk.Treeview( + tree_frame +) + +tree.pack( + expand=tk.YES, + fill=tk.BOTH, + padx=5, + pady=5, +) + +tree.bind( + "", + popup_menu, +) + +ttk.Sizegrip( + tree_frame +).pack( + side="right", + fill="y", +) + + +# Log +log_frame = tk.Frame( + win, + width=500, +) + +log_frame.pack( + side=tk.RIGHT, + fill=tk.BOTH, + expand=True, +) + +log_text = scrolledtext.ScrolledText( + log_frame, + height=10, + width=50, +) + +log_text.pack( + expand=tk.YES, + fill=tk.BOTH, +) + +log_text.config( + state=tk.DISABLED +) + + +win.protocol( + "WM_DELETE_WINDOW", + hide_window, +) -win.protocol('WM_DELETE_WINDOW', hide_window) start_application() + win.mainloop() \ No newline at end of file diff --git a/tests/test_organizer.py b/tests/test_organizer.py new file mode 100644 index 0000000..cbb3100 --- /dev/null +++ b/tests/test_organizer.py @@ -0,0 +1,321 @@ +import datetime +import os +from pathlib import Path + +import pytest + +from documents_organizer.services.organizer import ( + get_extension_name, + is_already_organized, + organize_directory, +) + + +TEST_DATE = "2026-08-25" + + +def set_test_modified_date(path: Path) -> None: + """Give a test file a deterministic modification date.""" + timestamp = datetime.datetime( + 2026, + 8, + 25, + 12, + 0, + 0, + ).timestamp() + + os.utime( + path, + ( + timestamp, + timestamp, + ), + ) + + +def test_organize_directory_moves_files_by_extension_and_date( + tmp_path: Path, +): + report = tmp_path / "report.pdf" + image = tmp_path / "photo.jpg" + + report.write_text("report") + image.write_text("image") + + set_test_modified_date(report) + set_test_modified_date(image) + + result = organize_directory( + tmp_path + ) + + expected_report = ( + tmp_path + / "pdf" + / TEST_DATE + / "report.pdf" + ) + + expected_image = ( + tmp_path + / "jpg" + / TEST_DATE + / "photo.jpg" + ) + + assert result.moved == 2 + assert result.skipped == 0 + assert result.failed == 0 + + assert result.by_extension == { + "pdf": 1, + "jpg": 1, + } + + assert expected_report.exists() + assert expected_image.exists() + + assert not report.exists() + assert not image.exists() + + +def test_organize_directory_preserves_nested_parent_directory( + tmp_path: Path, +): + nested = tmp_path / "project" + nested.mkdir() + + source = nested / "photo.jpg" + source.write_text("image") + + set_test_modified_date(source) + + result = organize_directory( + tmp_path + ) + + destination = ( + nested + / "jpg" + / TEST_DATE + / "photo.jpg" + ) + + assert result.moved == 1 + assert destination.exists() + + +def test_organize_directory_handles_files_without_extension( + tmp_path: Path, +): + source = tmp_path / "README" + source.write_text("readme") + + set_test_modified_date(source) + + result = organize_directory( + tmp_path + ) + + destination = ( + tmp_path + / "other" + / TEST_DATE + / "README" + ) + + assert result.moved == 1 + assert result.by_extension == { + "other": 1 + } + + assert destination.exists() + + +def test_organize_directory_ignores_system_files( + tmp_path: Path, +): + ds_store = tmp_path / ".DS_Store" + thumbs = tmp_path / "Thumbs.db" + + ds_store.write_text("system") + thumbs.write_text("system") + + result = organize_directory( + tmp_path + ) + + assert result.moved == 0 + assert result.skipped == 2 + assert result.failed == 0 + + assert ds_store.exists() + assert thumbs.exists() + + +def test_organize_directory_does_not_overwrite_existing_file( + tmp_path: Path, +): + source = tmp_path / "report.pdf" + source.write_text("new report") + + set_test_modified_date(source) + + destination_directory = ( + tmp_path + / "pdf" + / TEST_DATE + ) + + destination_directory.mkdir( + parents=True + ) + + existing = ( + destination_directory + / "report.pdf" + ) + + existing.write_text( + "existing report" + ) + + result = organize_directory( + tmp_path + ) + + renamed = ( + destination_directory + / "report (1).pdf" + ) + + assert result.moved == 1 + + assert existing.read_text() == ( + "existing report" + ) + + assert renamed.read_text() == ( + "new report" + ) + + +def test_running_organizer_twice_does_not_reorganize_files( + tmp_path: Path, +): + source = tmp_path / "report.pdf" + source.write_text("report") + + set_test_modified_date(source) + + first_result = organize_directory( + tmp_path + ) + + second_result = organize_directory( + tmp_path + ) + + organized_file = ( + tmp_path + / "pdf" + / TEST_DATE + / "report.pdf" + ) + + nested_duplicate = ( + tmp_path + / "pdf" + / TEST_DATE + / "pdf" + / TEST_DATE + / "report.pdf" + ) + + assert first_result.moved == 1 + + assert second_result.moved == 0 + assert second_result.skipped == 1 + + assert organized_file.exists() + assert not nested_duplicate.exists() + + +def test_get_extension_name_returns_other_for_extensionless_file(): + assert ( + get_extension_name( + Path("README") + ) + == "other" + ) + + +def test_organize_directory_rejects_missing_directory( + tmp_path: Path, +): + missing = ( + tmp_path + / "does-not-exist" + ) + + with pytest.raises( + FileNotFoundError + ): + organize_directory( + missing + ) + + +def test_organize_directory_rejects_file_as_root( + tmp_path: Path, +): + file_path = tmp_path / "file.txt" + file_path.write_text("test") + + with pytest.raises( + NotADirectoryError + ): + organize_directory( + file_path + ) + + +def test_is_already_organized_detects_organized_file( + tmp_path: Path, +): + organized_directory = ( + tmp_path + / "pdf" + / TEST_DATE + ) + + organized_directory.mkdir( + parents=True + ) + + file_path = ( + organized_directory + / "report.pdf" + ) + + file_path.write_text("report") + + assert is_already_organized( + file_path + ) + + +def test_is_already_organized_rejects_normal_file( + tmp_path: Path, +): + file_path = ( + tmp_path + / "report.pdf" + ) + + file_path.write_text("report") + + assert not is_already_organized( + file_path + ) \ No newline at end of file From e7b31e6451ff819c79c2715f7d3d060f254a67dc Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 22:03:13 -0400 Subject: [PATCH 03/20] Updated organizer to organize by date first then inside save by extension --- documents_organizer/services/organizer.py | 94 ++++++---- tests/test_organizer.py | 219 +++++++++++++++++----- 2 files changed, 234 insertions(+), 79 deletions(-) diff --git a/documents_organizer/services/organizer.py b/documents_organizer/services/organizer.py index 361ae35..2ce2eb6 100644 --- a/documents_organizer/services/organizer.py +++ b/documents_organizer/services/organizer.py @@ -36,23 +36,25 @@ def failed(self) -> int: def organize_directory(folder: Path | str) -> OrganizationResult: """ - Organize files beneath a directory by extension and modification date. + Organize all files beneath a directory by modified date and file type. - Files are first discovered before any files are moved. This prevents - directories created by the organization process from being discovered - and processed again during the same operation. + Files from nested directories are centralized into the selected root. Example: Downloads/ report.pdf + project/ + notes.txt becomes: Downloads/ - pdf/ - 2026-08-25/ + 2026-08-25/ + pdf/ report.pdf + txt/ + notes.txt """ root = Path(folder).resolve() @@ -74,8 +76,9 @@ def organize_directory(folder: Path | str) -> OrganizationResult: for source in files: _organize_file( - source, - result, + root=root, + source=source, + result=result, ) return result @@ -87,8 +90,8 @@ def _snapshot_files( """ Capture the files that exist before organization begins. - Taking a snapshot prevents newly-created extension/date directories - from being traversed by the current organization operation. + Taking a snapshot prevents directories created by the organizer from + being discovered and processed during the same operation. """ files: list[Path] = [] failures: list[OrganizationFailure] = [] @@ -123,6 +126,7 @@ def handle_walk_error(error: OSError) -> None: def _organize_file( + root: Path, source: Path, result: OrganizationResult, ) -> None: @@ -141,20 +145,25 @@ def _organize_file( result.skipped += 1 return - if is_already_organized(source): + if is_already_organized( + source, + root, + ): result.skipped += 1 return - extension_name = get_extension_name(source) + extension_name = get_extension_name( + source + ) modified_date = get_modified_date( source ) destination = ( - source.parent - / extension_name + root / modified_date + / extension_name / source.name ) @@ -189,9 +198,9 @@ def _organize_file( def get_extension_name(path: Path) -> str: """ - Return the directory name used for a file extension. + Return the folder name used for a file type. - Files without an extension are placed in the 'other' directory. + Files without an extension are placed in the 'other' folder. """ suffix = path.suffix.lower() @@ -202,7 +211,7 @@ def get_extension_name(path: Path) -> str: def get_modified_date(path: Path) -> str: - """Return the file modification date in YYYY-MM-DD format.""" + """Return the file's modified date in YYYY-MM-DD format.""" modified_timestamp = path.stat().st_mtime return datetime.datetime.fromtimestamp( @@ -210,40 +219,55 @@ def get_modified_date(path: Path) -> str: ).strftime("%Y-%m-%d") -def is_already_organized(path: Path) -> bool: +def is_already_organized( + path: Path, + root: Path, +) -> bool: """ - Return True when a file already appears to be in an organized location. + Return True when a file is already in the organizer's date/type layout. - Expected structure: + Expected layout relative to the selected root: - // + YYYY-MM-DD/ + extension/ + filename Example: - pdf/2026-08-25/report.pdf + 2026-08-25/ + pdf/ + report.pdf """ - date_directory = path.parent - - extension_directory = date_directory.parent + try: + relative_path = path.resolve().relative_to( + root.resolve() + ) + except ValueError: + return False - expected_extension = get_extension_name( - path - ) + parts = relative_path.parts - if ( - extension_directory.name.lower() - != expected_extension.lower() - ): + if len(parts) != 3: return False + date_directory = parts[0] + extension_directory = parts[1] + try: parsed_date = datetime.date.fromisoformat( - date_directory.name + date_directory ) except ValueError: return False + if parsed_date.isoformat() != date_directory: + return False + + expected_extension = get_extension_name( + path + ) + return ( - parsed_date.isoformat() - == date_directory.name + extension_directory.lower() + == expected_extension.lower() ) \ No newline at end of file diff --git a/tests/test_organizer.py b/tests/test_organizer.py index cbb3100..76806eb 100644 --- a/tests/test_organizer.py +++ b/tests/test_organizer.py @@ -34,7 +34,7 @@ def set_test_modified_date(path: Path) -> None: ) -def test_organize_directory_moves_files_by_extension_and_date( +def test_organize_directory_moves_files_by_date_then_extension( tmp_path: Path, ): report = tmp_path / "report.pdf" @@ -52,15 +52,15 @@ def test_organize_directory_moves_files_by_extension_and_date( expected_report = ( tmp_path - / "pdf" / TEST_DATE + / "pdf" / "report.pdf" ) expected_image = ( tmp_path - / "jpg" / TEST_DATE + / "jpg" / "photo.jpg" ) @@ -80,13 +80,13 @@ def test_organize_directory_moves_files_by_extension_and_date( assert not image.exists() -def test_organize_directory_preserves_nested_parent_directory( +def test_nested_files_are_centralized_into_selected_root( tmp_path: Path, ): - nested = tmp_path / "project" - nested.mkdir() + project = tmp_path / "project" + project.mkdir() - source = nested / "photo.jpg" + source = project / "photo.jpg" source.write_text("image") set_test_modified_date(source) @@ -96,14 +96,48 @@ def test_organize_directory_preserves_nested_parent_directory( ) destination = ( - nested - / "jpg" + tmp_path / TEST_DATE + / "jpg" / "photo.jpg" ) assert result.moved == 1 assert destination.exists() + assert not source.exists() + + +def test_files_from_multiple_nested_directories_are_centralized( + tmp_path: Path, +): + first_directory = tmp_path / "project-a" + second_directory = tmp_path / "project-b" + + first_directory.mkdir() + second_directory.mkdir() + + first_file = first_directory / "one.pdf" + second_file = second_directory / "two.pdf" + + first_file.write_text("one") + second_file.write_text("two") + + set_test_modified_date(first_file) + set_test_modified_date(second_file) + + result = organize_directory( + tmp_path + ) + + destination_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + assert result.moved == 2 + assert (destination_directory / "one.pdf").exists() + assert (destination_directory / "two.pdf").exists() def test_organize_directory_handles_files_without_extension( @@ -120,12 +154,13 @@ def test_organize_directory_handles_files_without_extension( destination = ( tmp_path - / "other" / TEST_DATE + / "other" / "README" ) assert result.moved == 1 + assert result.by_extension == { "other": 1 } @@ -154,7 +189,7 @@ def test_organize_directory_ignores_system_files( assert thumbs.exists() -def test_organize_directory_does_not_overwrite_existing_file( +def test_organizer_does_not_overwrite_existing_file( tmp_path: Path, ): source = tmp_path / "report.pdf" @@ -164,8 +199,8 @@ def test_organize_directory_does_not_overwrite_existing_file( destination_directory = ( tmp_path - / "pdf" / TEST_DATE + / "pdf" ) destination_directory.mkdir( @@ -201,6 +236,58 @@ def test_organize_directory_does_not_overwrite_existing_file( ) +def test_duplicate_names_from_different_folders_are_preserved( + tmp_path: Path, +): + first_directory = tmp_path / "project-a" + second_directory = tmp_path / "project-b" + + first_directory.mkdir() + second_directory.mkdir() + + first = first_directory / "report.pdf" + second = second_directory / "report.pdf" + + first.write_text("first") + second.write_text("second") + + set_test_modified_date(first) + set_test_modified_date(second) + + result = organize_directory( + tmp_path + ) + + destination_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + original_name = ( + destination_directory + / "report.pdf" + ) + + renamed = ( + destination_directory + / "report (1).pdf" + ) + + assert result.moved == 2 + + assert original_name.exists() + assert renamed.exists() + + assert { + original_name.read_text(), + renamed.read_text(), + } == { + "first", + "second", + } + + def test_running_organizer_twice_does_not_reorganize_files( tmp_path: Path, ): @@ -219,17 +306,17 @@ def test_running_organizer_twice_does_not_reorganize_files( organized_file = ( tmp_path - / "pdf" / TEST_DATE + / "pdf" / "report.pdf" ) nested_duplicate = ( tmp_path - / "pdf" / TEST_DATE / "pdf" / TEST_DATE + / "pdf" / "report.pdf" ) @@ -242,6 +329,15 @@ def test_running_organizer_twice_does_not_reorganize_files( assert not nested_duplicate.exists() +def test_get_extension_name_returns_extension_without_dot(): + assert ( + get_extension_name( + Path("report.PDF") + ) + == "pdf" + ) + + def test_get_extension_name_returns_other_for_extensionless_file(): assert ( get_extension_name( @@ -251,71 +347,106 @@ def test_get_extension_name_returns_other_for_extensionless_file(): ) -def test_organize_directory_rejects_missing_directory( +def test_is_already_organized_detects_date_type_layout( tmp_path: Path, ): - missing = ( + organized_directory = ( tmp_path - / "does-not-exist" + / TEST_DATE + / "pdf" ) - with pytest.raises( - FileNotFoundError - ): - organize_directory( - missing - ) + organized_directory.mkdir( + parents=True + ) + file_path = ( + organized_directory + / "report.pdf" + ) -def test_organize_directory_rejects_file_as_root( - tmp_path: Path, -): - file_path = tmp_path / "file.txt" - file_path.write_text("test") + file_path.write_text("report") - with pytest.raises( - NotADirectoryError - ): - organize_directory( - file_path - ) + assert is_already_organized( + file_path, + tmp_path, + ) -def test_is_already_organized_detects_organized_file( +def test_is_already_organized_rejects_old_extension_date_layout( tmp_path: Path, ): - organized_directory = ( + old_directory = ( tmp_path / "pdf" / TEST_DATE ) - organized_directory.mkdir( + old_directory.mkdir( parents=True ) file_path = ( - organized_directory + old_directory / "report.pdf" ) file_path.write_text("report") - assert is_already_organized( - file_path + assert not is_already_organized( + file_path, + tmp_path, ) -def test_is_already_organized_rejects_normal_file( +def test_is_already_organized_rejects_normal_nested_file( tmp_path: Path, ): + project = tmp_path / "project" + project.mkdir() + file_path = ( - tmp_path + project / "report.pdf" ) file_path.write_text("report") assert not is_already_organized( - file_path - ) \ No newline at end of file + file_path, + tmp_path, + ) + + +def test_organize_directory_rejects_missing_directory( + tmp_path: Path, +): + missing = ( + tmp_path + / "does-not-exist" + ) + + with pytest.raises( + FileNotFoundError + ): + organize_directory( + missing + ) + + +def test_organize_directory_rejects_file_as_root( + tmp_path: Path, +): + file_path = ( + tmp_path + / "file.txt" + ) + + file_path.write_text("test") + + with pytest.raises( + NotADirectoryError + ): + organize_directory( + file_path + ) \ No newline at end of file From a35919192db5b72884e318bf46b7423674dde759 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 22:08:48 -0400 Subject: [PATCH 04/20] Extract safe flattener service and add tests --- documents_organizer/services/flattener.py | 297 ++++++++++++ main.py | 269 +++++------ tests/test_flattener.py | 558 ++++++++++++++++++++++ 3 files changed, 965 insertions(+), 159 deletions(-) create mode 100644 tests/test_flattener.py diff --git a/documents_organizer/services/flattener.py b/documents_organizer/services/flattener.py index e69de29..e41c841 100644 --- a/documents_organizer/services/flattener.py +++ b/documents_organizer/services/flattener.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import datetime +import threading +from dataclasses import dataclass, field +from pathlib import Path + +from documents_organizer.filesystem import move_file_safely +from documents_organizer.services.organizer import get_extension_name + + +@dataclass(frozen=True) +class FlattenFailure: + """Represents a file or directory that could not be processed.""" + + path: Path + error: str + + +@dataclass +class FlattenResult: + """Summary of a flatten operation.""" + + moved: int = 0 + skipped: int = 0 + directories_removed: int = 0 + cancelled: bool = False + by_extension: dict[str, int] = field(default_factory=dict) + failures: list[FlattenFailure] = field(default_factory=list) + + @property + def failed(self) -> int: + """Return the number of failures.""" + return len(self.failures) + + +def flatten_directory( + folder: Path | str, + cancel_event: threading.Event | None = None, +) -> FlattenResult: + """ + Flatten a Documents Organizer date/type directory structure. + + Expected structure: + + selected-folder/ + YYYY-MM-DD/ + extension/ + filename + + Files are moved back into the selected root directory. + + Existing files are never silently overwritten. Duplicate names are + resolved by move_file_safely(). + + Only directories that match the organizer's expected structure are + processed. + """ + root = Path(folder).resolve() + + if not root.exists(): + raise FileNotFoundError( + f"Folder does not exist: {root}" + ) + + if not root.is_dir(): + raise NotADirectoryError( + f"Path is not a directory: {root}" + ) + + if cancel_event is None: + cancel_event = threading.Event() + + result = FlattenResult() + + try: + root_items = list(root.iterdir()) + except OSError as exc: + raise OSError( + f"Unable to read folder: {root}" + ) from exc + + date_directories = [ + item + for item in root_items + if item.is_dir() + and is_date_directory(item) + ] + + for date_directory in sorted( + date_directories + ): + if cancel_event.is_set(): + result.cancelled = True + return result + + _flatten_date_directory( + root=root, + date_directory=date_directory, + cancel_event=cancel_event, + result=result, + ) + + if cancel_event.is_set(): + result.cancelled = True + return result + + _remove_if_empty( + date_directory, + result, + ) + + return result + + +def _flatten_date_directory( + root: Path, + date_directory: Path, + cancel_event: threading.Event, + result: FlattenResult, +) -> None: + """Flatten the file-type directories inside one date directory.""" + try: + items = list( + date_directory.iterdir() + ) + except OSError as exc: + result.failures.append( + FlattenFailure( + path=date_directory, + error=str(exc), + ) + ) + return + + type_directories = [ + item + for item in items + if item.is_dir() + ] + + for type_directory in sorted( + type_directories + ): + if cancel_event.is_set(): + return + + _flatten_type_directory( + root=root, + type_directory=type_directory, + cancel_event=cancel_event, + result=result, + ) + + if cancel_event.is_set(): + return + + _remove_if_empty( + type_directory, + result, + ) + + +def _flatten_type_directory( + root: Path, + type_directory: Path, + cancel_event: threading.Event, + result: FlattenResult, +) -> None: + """Move valid files from one file-type directory back to the root.""" + try: + items = list( + type_directory.iterdir() + ) + except OSError as exc: + result.failures.append( + FlattenFailure( + path=type_directory, + error=str(exc), + ) + ) + return + + for source in items: + if cancel_event.is_set(): + return + + if not source.is_file(): + continue + + expected_type = get_extension_name( + source + ) + + actual_type = ( + type_directory.name.lower() + ) + + if ( + actual_type + != expected_type.lower() + ): + result.skipped += 1 + continue + + destination = ( + root + / source.name + ) + + try: + final_destination = ( + move_file_safely( + source, + destination, + ) + ) + + result.moved += 1 + + result.by_extension[ + expected_type + ] = ( + result.by_extension.get( + expected_type, + 0, + ) + + 1 + ) + + except ( + FileNotFoundError, + PermissionError, + OSError, + ValueError, + ) as exc: + result.failures.append( + FlattenFailure( + path=source, + error=str(exc), + ) + ) + + +def _remove_if_empty( + directory: Path, + result: FlattenResult, +) -> None: + """ + Remove a directory only when it is completely empty. + + This deliberately uses rmdir() instead of recursive deletion so the + flattener cannot accidentally delete unexpected contents. + """ + try: + if not directory.exists(): + return + + if not directory.is_dir(): + return + + if any(directory.iterdir()): + return + + directory.rmdir() + + result.directories_removed += 1 + + except OSError as exc: + result.failures.append( + FlattenFailure( + path=directory, + error=str(exc), + ) + ) + + +def is_date_directory( + path: Path, +) -> bool: + """Return True when a directory name is an ISO YYYY-MM-DD date.""" + if not path.is_dir(): + return False + + try: + parsed_date = ( + datetime.date.fromisoformat( + path.name + ) + ) + except ValueError: + return False + + return ( + parsed_date.isoformat() + == path.name + ) \ No newline at end of file diff --git a/main.py b/main.py index 3bd8427..798b8b7 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ import os -import shutil import threading import tkinter as tk import tkinter.scrolledtext as scrolledtext @@ -10,16 +9,17 @@ from pystray import MenuItem as item from documents_organizer import __version__ -from documents_organizer.filesystem import move_file_safely from documents_organizer.platform_utils import open_in_file_manager from documents_organizer.services.organizer import ( OrganizationResult, organize_directory, ) +from documents_organizer.services.flattener import ( + FlattenResult, + flatten_directory, +) - -# Define a global flag for canceling flattening operation -cancel_flattening = False +flatten_cancel_event = threading.Event() # Global variable to store the folder path folder_path = "" @@ -136,9 +136,8 @@ def _handle_organization_error( message, ) - def flatten_folders(): - """Flatten folders based on specified extensions.""" + """Start a flatten operation for the selected folder.""" selected_item = tree.focus() if not selected_item: @@ -153,184 +152,135 @@ def flatten_folders(): selected_item, ) - if not selected_folder: + if not os.path.isdir( + selected_folder + ): messagebox.showerror( "Error", - "Unable to determine folder path.", + "The selected folder does not exist.", ) return - global cancel_flattening - cancel_flattening = False + flatten_cancel_event.clear() + + log_to_text( + f"Flattening: {selected_folder}" + ) - threading.Thread( - target=flatten_folder_recursive, + worker = threading.Thread( + target=_run_flattener, args=(selected_folder,), daemon=True, - ).start() - - -def flatten_folder_recursive(folder): - """Recursively flatten folders.""" - global cancel_flattening + ) - if cancel_flattening: - log_to_text("Flattening operation canceled.") - return + worker.start() +def _run_flattener( + selected_folder, +): + """Run the flattener service outside the Tkinter main thread.""" try: - for root, dirs, files in os.walk(folder): - if cancel_flattening: - log_to_text( - "Flattening operation canceled." - ) - return - - for directory in dirs[:]: - if cancel_flattening: - log_to_text( - "Flattening operation canceled." - ) - return - - dir_path = os.path.join( - root, - directory, - ) - - if ( - os.path.basename(directory).lower() - in extensions_to_flatten - ): - flatten_subfolders(dir_path) - - if cancel_flattening: - log_to_text( - "Flattening operation canceled." - ) - return - - move_files_to_parent(dir_path) - - dirs.remove(directory) - - else: - if not cancel_flattening: - flatten_folder_recursive( - dir_path - ) - - if not os.path.isdir(folder): - return - - remaining_items = os.listdir(folder) - - if all( - item_name.lower() - not in extensions_to_flatten - for item_name in remaining_items - ): - log_to_text( - f"{os.path.basename(folder)} " - "is clean of extension folders." - ) - - refresh_treeview() - - log_to_text( - f"All subfolders in " - f"{os.path.basename(folder)} flattened." + result = flatten_directory( + selected_folder, + cancel_event=flatten_cancel_event, ) - except Exception as exc: - log_to_text( - f"Error flattening folders: {exc}" + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + win.after( + 0, + _handle_flatten_error, + str(exc), ) + return -def flatten_subfolders(folder): - """Flatten subfolders of the specified folder.""" - global cancel_flattening - - for root, dirs, files in os.walk(folder): - if cancel_flattening: - return - - for filename in files: - if cancel_flattening: - return - - src = os.path.join( - root, - filename, - ) - - destination = os.path.join( - folder, - filename, - ) - - # A file already located directly in the target folder - # does not need to be moved. - if os.path.abspath(src) == os.path.abspath( - destination - ): - continue - - move_file_safely( - src, - destination, - ) + win.after( + 0, + _handle_flatten_result, + result, + ) - for root, dirs, files in os.walk( - folder, - topdown=False, +def _handle_flatten_result( + result: FlattenResult, +): + """Display flatten results on the Tkinter main thread.""" + for extension, count in sorted( + result.by_extension.items() ): - if cancel_flattening: - return - - for directory in dirs: - directory_path = os.path.join( - root, - directory, - ) - - if os.path.isdir(directory_path): - shutil.rmtree(directory_path) + label = ( + "file" + if count == 1 + else "files" + ) + log_to_text( + f"Flattened {count} " + f"{extension} {label}." + ) -def move_files_to_parent(folder): - """Move files from a folder to its parent folder.""" - global cancel_flattening + if result.skipped: + label = ( + "file" + if result.skipped == 1 + else "files" + ) - parent_folder = os.path.dirname(folder) + log_to_text( + f"Skipped {result.skipped} " + f"{label} that did not match " + "their file-type folder." + ) - for root, dirs, files in os.walk(folder): - if cancel_flattening: - return + if result.failed: + label = ( + "failure" + if result.failed == 1 + else "failures" + ) - for filename in files: - if cancel_flattening: - return + log_to_text( + f"Encountered " + f"{result.failed} {label}." + ) - src = os.path.join( - root, - filename, + for failure in result.failures: + log_to_text( + f" {failure.path}: " + f"{failure.error}" ) - destination = os.path.join( - parent_folder, - filename, - ) + if result.cancelled: + log_to_text( + "Flattening canceled." + ) + else: + log_to_text( + f"Flattening complete. " + f"{result.moved} files moved " + f"and " + f"{result.directories_removed} " + f"empty folders removed." + ) - move_file_safely( - src, - destination, - ) + refresh_treeview() - if os.path.isdir(folder): - shutil.rmtree(folder) +def _handle_flatten_error( + message: str, +): + """Display a fatal flatten error.""" + log_to_text( + f"Flattening failed: {message}" + ) + messagebox.showerror( + "Flattening Failed", + message, + ) def get_full_path(tree, item): """Get the full path of a selected item in the Treeview.""" @@ -397,10 +347,11 @@ def input_extensions(existing_extensions): def stop_flattening(): - """Stop the flattening operation.""" - global cancel_flattening + """Request cancellation of the active flatten operation.""" + if flatten_cancel_event.is_set(): + return - cancel_flattening = True + flatten_cancel_event.set() log_to_text( "Cancel requested..." diff --git a/tests/test_flattener.py b/tests/test_flattener.py new file mode 100644 index 0000000..e4a3313 --- /dev/null +++ b/tests/test_flattener.py @@ -0,0 +1,558 @@ +import threading +from pathlib import Path + +import pytest + +from documents_organizer.services.flattener import ( + flatten_directory, + is_date_directory, +) + + +TEST_DATE = "2026-08-25" + + +def test_flatten_directory_moves_files_back_to_root( + tmp_path: Path, +): + pdf_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + pdf_directory.mkdir( + parents=True + ) + + source = ( + pdf_directory + / "report.pdf" + ) + + source.write_text("report") + + result = flatten_directory( + tmp_path + ) + + destination = ( + tmp_path + / "report.pdf" + ) + + assert result.moved == 1 + assert result.skipped == 0 + assert result.failed == 0 + assert not result.cancelled + + assert destination.exists() + assert destination.read_text() == ( + "report" + ) + + assert not source.exists() + + +def test_flatten_directory_handles_multiple_file_types( + tmp_path: Path, +): + pdf_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + jpg_directory = ( + tmp_path + / TEST_DATE + / "jpg" + ) + + pdf_directory.mkdir( + parents=True + ) + + jpg_directory.mkdir( + parents=True + ) + + report = ( + pdf_directory + / "report.pdf" + ) + + image = ( + jpg_directory + / "photo.jpg" + ) + + report.write_text("report") + image.write_text("photo") + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 2 + + assert result.by_extension == { + "pdf": 1, + "jpg": 1, + } + + assert ( + tmp_path / "report.pdf" + ).exists() + + assert ( + tmp_path / "photo.jpg" + ).exists() + + +def test_flatten_directory_preserves_duplicate_names( + tmp_path: Path, +): + first_date = ( + tmp_path + / "2026-08-24" + / "pdf" + ) + + second_date = ( + tmp_path + / "2026-08-25" + / "pdf" + ) + + first_date.mkdir( + parents=True + ) + + second_date.mkdir( + parents=True + ) + + first = ( + first_date + / "report.pdf" + ) + + second = ( + second_date + / "report.pdf" + ) + + first.write_text("first") + second.write_text("second") + + result = flatten_directory( + tmp_path + ) + + original = ( + tmp_path + / "report.pdf" + ) + + renamed = ( + tmp_path + / "report (1).pdf" + ) + + assert result.moved == 2 + + assert original.exists() + assert renamed.exists() + + assert { + original.read_text(), + renamed.read_text(), + } == { + "first", + "second", + } + + +def test_flatten_directory_does_not_overwrite_existing_root_file( + tmp_path: Path, +): + existing = ( + tmp_path + / "report.pdf" + ) + + existing.write_text( + "existing" + ) + + organized_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + organized_directory.mkdir( + parents=True + ) + + source = ( + organized_directory + / "report.pdf" + ) + + source.write_text( + "organized" + ) + + result = flatten_directory( + tmp_path + ) + + renamed = ( + tmp_path + / "report (1).pdf" + ) + + assert result.moved == 1 + + assert existing.read_text() == ( + "existing" + ) + + assert renamed.read_text() == ( + "organized" + ) + + +def test_flatten_directory_handles_other_files( + tmp_path: Path, +): + other_directory = ( + tmp_path + / TEST_DATE + / "other" + ) + + other_directory.mkdir( + parents=True + ) + + source = ( + other_directory + / "README" + ) + + source.write_text("readme") + + result = flatten_directory( + tmp_path + ) + + destination = ( + tmp_path + / "README" + ) + + assert result.moved == 1 + assert destination.exists() + + +def test_flatten_directory_skips_file_in_wrong_type_directory( + tmp_path: Path, +): + pdf_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + pdf_directory.mkdir( + parents=True + ) + + source = ( + pdf_directory + / "photo.jpg" + ) + + source.write_text("photo") + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 0 + assert result.skipped == 1 + + assert source.exists() + + assert not ( + tmp_path + / "photo.jpg" + ).exists() + + +def test_flatten_directory_ignores_normal_directories( + tmp_path: Path, +): + normal_directory = ( + tmp_path + / "project" + / "pdf" + ) + + normal_directory.mkdir( + parents=True + ) + + source = ( + normal_directory + / "report.pdf" + ) + + source.write_text("report") + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 0 + + assert source.exists() + + +def test_flatten_directory_ignores_invalid_date_directory( + tmp_path: Path, +): + invalid_directory = ( + tmp_path + / "2026-99-99" + / "pdf" + ) + + invalid_directory.mkdir( + parents=True + ) + + source = ( + invalid_directory + / "report.pdf" + ) + + source.write_text("report") + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 0 + assert source.exists() + + +def test_flatten_directory_removes_empty_organizer_directories( + tmp_path: Path, +): + directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + directory.mkdir( + parents=True + ) + + source = ( + directory + / "report.pdf" + ) + + source.write_text("report") + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 1 + + assert not ( + tmp_path + / TEST_DATE + / "pdf" + ).exists() + + assert not ( + tmp_path + / TEST_DATE + ).exists() + + assert ( + result.directories_removed + == 2 + ) + + +def test_flatten_directory_does_not_remove_directory_with_unexpected_content( + tmp_path: Path, +): + pdf_directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + unexpected_directory = ( + pdf_directory + / "keep-me" + ) + + unexpected_directory.mkdir( + parents=True + ) + + source = ( + pdf_directory + / "report.pdf" + ) + + source.write_text("report") + + unexpected_file = ( + unexpected_directory + / "something.txt" + ) + + unexpected_file.write_text( + "keep this" + ) + + result = flatten_directory( + tmp_path + ) + + assert result.moved == 1 + + assert unexpected_file.exists() + + assert pdf_directory.exists() + + assert ( + tmp_path + / TEST_DATE + ).exists() + + +def test_flatten_directory_respects_cancellation( + tmp_path: Path, +): + directory = ( + tmp_path + / TEST_DATE + / "pdf" + ) + + directory.mkdir( + parents=True + ) + + source = ( + directory + / "report.pdf" + ) + + source.write_text("report") + + cancel_event = ( + threading.Event() + ) + + cancel_event.set() + + result = flatten_directory( + tmp_path, + cancel_event=cancel_event, + ) + + assert result.cancelled + assert result.moved == 0 + assert source.exists() + + +def test_flatten_directory_rejects_missing_directory( + tmp_path: Path, +): + missing = ( + tmp_path + / "missing" + ) + + with pytest.raises( + FileNotFoundError + ): + flatten_directory( + missing + ) + + +def test_flatten_directory_rejects_file_as_root( + tmp_path: Path, +): + file_path = ( + tmp_path + / "file.txt" + ) + + file_path.write_text("test") + + with pytest.raises( + NotADirectoryError + ): + flatten_directory( + file_path + ) + + +def test_is_date_directory_accepts_valid_iso_date( + tmp_path: Path, +): + directory = ( + tmp_path + / TEST_DATE + ) + + directory.mkdir() + + assert is_date_directory( + directory + ) + + +def test_is_date_directory_rejects_invalid_date( + tmp_path: Path, +): + directory = ( + tmp_path + / "2026-99-99" + ) + + directory.mkdir() + + assert not is_date_directory( + directory + ) + + +def test_is_date_directory_rejects_normal_folder_name( + tmp_path: Path, +): + directory = ( + tmp_path + / "documents" + ) + + directory.mkdir() + + assert not is_date_directory( + directory + ) \ No newline at end of file From fcd5bce084897e4f84fe5c691c5b5092baa85c6c Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 22:17:33 -0400 Subject: [PATCH 05/20] Extract Tkinter UI into MainWindow --- documents_organizer/app.py | 5 +- documents_organizer/ui/__init__.py | 1 + documents_organizer/ui/main_window.py | 1137 +++++++++++++++++++++++++ main.py | 937 +------------------- 4 files changed, 1145 insertions(+), 935 deletions(-) diff --git a/documents_organizer/app.py b/documents_organizer/app.py index a0ba5a7..cf894b2 100644 --- a/documents_organizer/app.py +++ b/documents_organizer/app.py @@ -4,8 +4,11 @@ def run() -> None: + """Start Documents Organizer.""" root = tk.Tk() - MainWindow(root) + app = MainWindow( + root + ) root.mainloop() \ No newline at end of file diff --git a/documents_organizer/ui/__init__.py b/documents_organizer/ui/__init__.py index e69de29..88cd438 100644 --- a/documents_organizer/ui/__init__.py +++ b/documents_organizer/ui/__init__.py @@ -0,0 +1 @@ +"""User interface components for Documents Organizer.""" \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index e69de29..0980c5c 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -0,0 +1,1137 @@ +from __future__ import annotations + +import os +import queue +import threading +import tkinter as tk +import tkinter.scrolledtext as scrolledtext +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +import pystray +from PIL import Image +from pystray import MenuItem as TrayMenuItem + +from documents_organizer import __version__ +from documents_organizer.platform_utils import open_in_file_manager +from documents_organizer.services.flattener import ( + FlattenResult, + flatten_directory, +) +from documents_organizer.services.organizer import ( + OrganizationResult, + organize_directory, +) + + +class MainWindow: + """Main Documents Organizer application window.""" + + def __init__(self, root: tk.Tk) -> None: + self.root = root + + self.folder_path: Path | None = None + self.flatten_cancel_event = threading.Event() + self.current_operation: str | None = None + self.tray_icon: pystray.Icon | None = None + + # Worker threads communicate with Tkinter through this queue. + # Tkinter itself is only updated from the main thread. + self.ui_queue: queue.Queue[ + tuple[str, object] + ] = queue.Queue() + + self._configure_window() + self._create_menu_bar() + self._create_layout() + self._bind_events() + + self._log_startup_message() + + # Start polling for messages from background workers. + self.root.after( + 50, + self._process_ui_queue, + ) + + # ------------------------------------------------------------------------- + # Window setup + # ------------------------------------------------------------------------- + + def _configure_window(self) -> None: + """Configure the root application window.""" + self.root.title( + "Documents Organizer" + ) + + self.root.geometry( + "1080x800" + ) + + self.root.minsize( + 800, + 600, + ) + + try: + self.root.iconbitmap( + "images/folder-256.ico" + ) + except tk.TclError: + # The ICO file may not be supported on every platform. + pass + + def _bind_events(self) -> None: + """Bind application-level window events.""" + self.root.protocol( + "WM_DELETE_WINDOW", + self.hide_window, + ) + + # ------------------------------------------------------------------------- + # Menus + # ------------------------------------------------------------------------- + + def _create_menu_bar(self) -> None: + """Create the application menu bar.""" + menu_bar = tk.Menu( + self.root + ) + + self.root.config( + menu=menu_bar + ) + + # File + file_menu = tk.Menu( + menu_bar, + tearoff=0, + ) + + file_menu.add_command( + label="Select Folder", + command=self.select_folder, + ) + + file_menu.add_separator() + + file_menu.add_command( + label="Exit", + command=self.exit_app, + ) + + menu_bar.add_cascade( + label="File", + menu=file_menu, + ) + + # Actions + action_menu = tk.Menu( + menu_bar, + tearoff=0, + ) + + organize_menu = tk.Menu( + action_menu, + tearoff=0, + ) + + organize_menu.add_command( + label="Organize Files", + command=self.run_organizer, + ) + + organize_menu.add_command( + label="Flatten Files", + command=self.run_flattener, + ) + + organize_menu.add_separator() + + organize_menu.add_command( + label="Cancel Flatten Operation", + command=self.stop_flattening, + ) + + action_menu.add_cascade( + label="Organize", + menu=organize_menu, + ) + + view_menu = tk.Menu( + action_menu, + tearoff=0, + ) + + view_menu.add_command( + label="Clear Log", + command=self.clear_log, + ) + + view_menu.add_command( + label="Refresh Folder Tree", + command=self.refresh_treeview, + ) + + action_menu.add_cascade( + label="View", + menu=view_menu, + ) + + menu_bar.add_cascade( + label="Action", + menu=action_menu, + ) + + # Help + help_menu = tk.Menu( + menu_bar, + tearoff=0, + ) + + help_menu.add_command( + label="About", + command=self.show_about, + ) + + menu_bar.add_cascade( + label="Help", + menu=help_menu, + ) + + # ------------------------------------------------------------------------- + # Main layout + # ------------------------------------------------------------------------- + + def _create_layout(self) -> None: + """Create the primary application layout.""" + self.main_frame = ttk.Frame( + self.root + ) + + self.main_frame.pack( + fill=tk.BOTH, + expand=True, + ) + + self.paned_window = ttk.PanedWindow( + self.main_frame, + orient=tk.HORIZONTAL, + ) + + self.paned_window.pack( + fill=tk.BOTH, + expand=True, + padx=8, + pady=8, + ) + + self._create_tree_panel() + self._create_log_panel() + + def _create_tree_panel(self) -> None: + """Create the directory tree panel.""" + tree_frame = ttk.Frame( + self.paned_window + ) + + self.paned_window.add( + tree_frame, + weight=1, + ) + + self.tree = ttk.Treeview( + tree_frame + ) + + tree_scrollbar = ttk.Scrollbar( + tree_frame, + orient=tk.VERTICAL, + command=self.tree.yview, + ) + + self.tree.configure( + yscrollcommand=tree_scrollbar.set + ) + + self.tree.pack( + side=tk.LEFT, + fill=tk.BOTH, + expand=True, + ) + + tree_scrollbar.pack( + side=tk.RIGHT, + fill=tk.Y, + ) + + self.tree.bind( + "", + self.popup_menu, + ) + + def _create_log_panel(self) -> None: + """Create the activity log panel.""" + log_frame = ttk.Frame( + self.paned_window + ) + + self.paned_window.add( + log_frame, + weight=1, + ) + + self.log_text = ( + scrolledtext.ScrolledText( + log_frame, + wrap=tk.WORD, + state=tk.DISABLED, + ) + ) + + self.log_text.pack( + fill=tk.BOTH, + expand=True, + ) + + # ------------------------------------------------------------------------- + # Folder selection and tree + # ------------------------------------------------------------------------- + + def select_folder(self) -> None: + """Allow the user to select a directory.""" + selected_folder = ( + filedialog.askdirectory() + ) + + if not selected_folder: + return + + self.folder_path = Path( + selected_folder + ).resolve() + + self.update_treeview( + self.folder_path + ) + + self.log_to_text( + f"Selected folder: " + f"{self.folder_path}" + ) + + def update_treeview( + self, + directory: Path | str, + ) -> None: + """Update the folder tree with a directory structure.""" + self.tree.delete( + *self.tree.get_children() + ) + + self.populate_tree( + Path(directory) + ) + + def populate_tree( + self, + directory: Path, + ) -> None: + """Populate the root folder in the Treeview.""" + root_node = self.tree.insert( + "", + "end", + text=str(directory), + ) + + self.populate_children( + root_node, + directory, + ) + + def populate_children( + self, + parent: str, + directory: Path, + ) -> None: + """Populate subdirectories beneath a Treeview node.""" + try: + items = sorted( + directory.iterdir(), + key=lambda path: path.name.lower(), + ) + + except ( + PermissionError, + FileNotFoundError, + OSError, + ): + return + + for item_path in items: + if not item_path.is_dir(): + continue + + node = self.tree.insert( + parent, + "end", + text=item_path.name, + ) + + self.populate_children( + node, + item_path, + ) + + def refresh_treeview(self) -> None: + """Refresh the displayed directory tree.""" + if self.folder_path is None: + return + + if not self.folder_path.is_dir(): + return + + self.update_treeview( + self.folder_path + ) + + def get_selected_folder( + self, + ) -> Path | None: + """Return the full path for the currently selected tree item.""" + selected_items = ( + self.tree.selection() + ) + + if not selected_items: + focused_item = ( + self.tree.focus() + ) + + if not focused_item: + return None + + selected_item = focused_item + + else: + selected_item = ( + selected_items[0] + ) + + return self.get_full_path( + selected_item + ) + + def get_full_path( + self, + item: str, + ) -> Path: + """Build the filesystem path represented by a Treeview item.""" + path_components = [ + self.tree.item(item)["text"] + ] + + parent = self.tree.parent( + item + ) + + while parent: + path_components.insert( + 0, + self.tree.item(parent)["text"], + ) + + parent = self.tree.parent( + parent + ) + + return Path( + os.path.join( + *path_components + ) + ).resolve() + + # ------------------------------------------------------------------------- + # Organizer + # ------------------------------------------------------------------------- + + def run_organizer(self) -> None: + """Start an organize operation for the selected folder.""" + selected_folder = ( + self.get_selected_folder() + ) + + if selected_folder is None: + messagebox.showerror( + "No Folder Selected", + "Please select a folder first.", + ) + return + + if not selected_folder.is_dir(): + messagebox.showerror( + "Invalid Folder", + "The selected folder does not exist.", + ) + return + + if not self._begin_operation( + "organize" + ): + return + + self.log_to_text( + f"Organizing: {selected_folder}" + ) + + worker = threading.Thread( + target=self._run_organizer_worker, + args=(selected_folder,), + daemon=True, + ) + + worker.start() + + def _run_organizer_worker( + self, + selected_folder: Path, + ) -> None: + """Run the organizer service on a background thread.""" + try: + result = organize_directory( + selected_folder + ) + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + self.ui_queue.put( + ( + "organization_error", + str(exc), + ) + ) + return + + self.ui_queue.put( + ( + "organization_result", + result, + ) + ) + + def _handle_organization_result( + self, + result: OrganizationResult, + ) -> None: + """Display organizer results.""" + for extension, count in sorted( + result.by_extension.items() + ): + label = ( + "file" + if count == 1 + else "files" + ) + + self.log_to_text( + f"Organized {count} " + f"{extension} {label}." + ) + + if result.skipped: + label = ( + "file" + if result.skipped == 1 + else "files" + ) + + self.log_to_text( + f"Skipped " + f"{result.skipped} {label}." + ) + + if result.failed: + label = ( + "failure" + if result.failed == 1 + else "failures" + ) + + self.log_to_text( + f"Encountered " + f"{result.failed} {label}." + ) + + for failure in result.failures: + self.log_to_text( + f" {failure.path}: " + f"{failure.error}" + ) + + self.log_to_text( + f"Organization complete. " + f"{result.moved} files moved." + ) + + self.current_operation = None + + self.refresh_treeview() + + def _handle_organization_error( + self, + message: str, + ) -> None: + """Display a fatal organizer error.""" + self.current_operation = None + + self.log_to_text( + f"Organization failed: " + f"{message}" + ) + + messagebox.showerror( + "Organization Failed", + message, + ) + + # ------------------------------------------------------------------------- + # Flattener + # ------------------------------------------------------------------------- + + def run_flattener(self) -> None: + """Start a flatten operation for the selected folder.""" + selected_folder = ( + self.get_selected_folder() + ) + + if selected_folder is None: + messagebox.showerror( + "No Folder Selected", + "Please select a folder first.", + ) + return + + if not selected_folder.is_dir(): + messagebox.showerror( + "Invalid Folder", + "The selected folder does not exist.", + ) + return + + if not self._begin_operation( + "flatten" + ): + return + + self.flatten_cancel_event.clear() + + self.log_to_text( + f"Flattening: {selected_folder}" + ) + + worker = threading.Thread( + target=self._run_flattener_worker, + args=(selected_folder,), + daemon=True, + ) + + worker.start() + + def _run_flattener_worker( + self, + selected_folder: Path, + ) -> None: + """Run the flattener service on a background thread.""" + try: + result = flatten_directory( + selected_folder, + cancel_event=( + self.flatten_cancel_event + ), + ) + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + self.ui_queue.put( + ( + "flatten_error", + str(exc), + ) + ) + return + + self.ui_queue.put( + ( + "flatten_result", + result, + ) + ) + + def _handle_flatten_result( + self, + result: FlattenResult, + ) -> None: + """Display flattener results.""" + for extension, count in sorted( + result.by_extension.items() + ): + label = ( + "file" + if count == 1 + else "files" + ) + + self.log_to_text( + f"Flattened {count} " + f"{extension} {label}." + ) + + if result.skipped: + label = ( + "file" + if result.skipped == 1 + else "files" + ) + + self.log_to_text( + f"Skipped " + f"{result.skipped} {label} " + "that did not match their " + "file-type folder." + ) + + if result.failed: + label = ( + "failure" + if result.failed == 1 + else "failures" + ) + + self.log_to_text( + f"Encountered " + f"{result.failed} {label}." + ) + + for failure in result.failures: + self.log_to_text( + f" {failure.path}: " + f"{failure.error}" + ) + + if result.cancelled: + self.log_to_text( + "Flattening canceled." + ) + + else: + self.log_to_text( + f"Flattening complete. " + f"{result.moved} files moved " + f"and " + f"{result.directories_removed} " + f"empty folders removed." + ) + + self.current_operation = None + + self.refresh_treeview() + + def _handle_flatten_error( + self, + message: str, + ) -> None: + """Display a fatal flattener error.""" + self.current_operation = None + + self.log_to_text( + f"Flattening failed: " + f"{message}" + ) + + messagebox.showerror( + "Flattening Failed", + message, + ) + + def stop_flattening(self) -> None: + """Request cancellation of the active flatten operation.""" + if self.current_operation != "flatten": + self.log_to_text( + "No flatten operation is currently running." + ) + return + + if self.flatten_cancel_event.is_set(): + return + + self.flatten_cancel_event.set() + + self.log_to_text( + "Cancel requested..." + ) + + # ------------------------------------------------------------------------- + # Operation state + # ------------------------------------------------------------------------- + + def _begin_operation( + self, + operation: str, + ) -> bool: + """Prevent conflicting filesystem operations from running together.""" + if self.current_operation is not None: + messagebox.showwarning( + "Operation in Progress", + ( + "Another file operation is " + "already running. Please wait " + "for it to finish." + ), + ) + + return False + + self.current_operation = ( + operation + ) + + return True + + # ------------------------------------------------------------------------- + # Worker → UI communication + # ------------------------------------------------------------------------- + + def _process_ui_queue(self) -> None: + """ + Process messages from worker threads. + + This method runs on the Tkinter main thread so worker threads never + directly modify Tkinter widgets. + """ + try: + while True: + event_name, payload = ( + self.ui_queue.get_nowait() + ) + + if ( + event_name + == "organization_result" + ): + self._handle_organization_result( + payload + ) + + elif ( + event_name + == "organization_error" + ): + self._handle_organization_error( + str(payload) + ) + + elif ( + event_name + == "flatten_result" + ): + self._handle_flatten_result( + payload + ) + + elif ( + event_name + == "flatten_error" + ): + self._handle_flatten_error( + str(payload) + ) + elif ( + event_name + == "show_window" + ): + self.root.deiconify() + + self.root.lift() + + self.root.focus_force() + + elif ( + event_name + == "exit_application" + ): + self.root.destroy() + + return + + except queue.Empty: + pass + + self.root.after( + 50, + self._process_ui_queue, + ) + + # ------------------------------------------------------------------------- + # Activity log + # ------------------------------------------------------------------------- + + def _log_startup_message(self) -> None: + """Display initial application information.""" + self.log_to_text( + f"Documents Organizer " + f"v{__version__}" + ) + + self.log_to_text( + "Ready." + ) + + def log_to_text( + self, + message: str, + ) -> None: + """Append a message to the activity log.""" + self.log_text.config( + state=tk.NORMAL + ) + + self.log_text.insert( + tk.END, + message + "\n", + ) + + self.log_text.config( + state=tk.DISABLED + ) + + self.log_text.see( + tk.END + ) + + def clear_log(self) -> None: + """Clear the activity log.""" + self.log_text.config( + state=tk.NORMAL + ) + + self.log_text.delete( + "1.0", + tk.END, + ) + + self.log_text.config( + state=tk.DISABLED + ) + + self._log_startup_message() + + # ------------------------------------------------------------------------- + # File manager context menu + # ------------------------------------------------------------------------- + + def popup_menu( + self, + event: tk.Event, + ) -> None: + """Display the folder context menu.""" + selected_item = ( + self.tree.identify_row( + event.y + ) + ) + + if not selected_item: + return + + self.tree.selection_set( + selected_item + ) + + self.tree.focus( + selected_item + ) + + popup = tk.Menu( + self.root, + tearoff=0, + ) + + popup.add_command( + label="Open in File Manager", + command=self.open_selected_folder, + ) + + popup.post( + event.x_root, + event.y_root, + ) + + def open_selected_folder(self) -> None: + """Open the selected folder using the platform file manager.""" + selected_folder = ( + self.get_selected_folder() + ) + + if selected_folder is None: + messagebox.showerror( + "No Folder Selected", + "Please select a folder first.", + ) + return + + try: + open_in_file_manager( + selected_folder + ) + + except ( + FileNotFoundError, + NotADirectoryError, + OSError, + ) as exc: + messagebox.showerror( + "Unable to Open Folder", + str(exc), + ) + + # ------------------------------------------------------------------------- + # System tray + # ------------------------------------------------------------------------- + + def hide_window(self) -> None: + """Hide the main window and create a system tray icon.""" + self.root.withdraw() + + if self.tray_icon is not None: + return + + try: + image = Image.open( + "images/folder-256.png" + ) + + tray_menu = ( + TrayMenuItem( + "Show", + self.show_window, + ), + TrayMenuItem( + "Quit", + self.exit_from_tray, + ), + ) + + self.tray_icon = pystray.Icon( + "DocumentsOrganizer", + image, + "Documents Organizer", + tray_menu, + ) + + threading.Thread( + target=self.tray_icon.run, + daemon=True, + ).start() + + except Exception as exc: + self.tray_icon = None + + self.root.deiconify() + + messagebox.showerror( + "System Tray Error", + ( + "Documents Organizer could " + "not create the system tray " + f"icon.\n\n{exc}" + ), + ) + + def show_window( + self, + icon: pystray.Icon, + menu_item: object, + ) -> None: + """Restore the main application window from the tray.""" + icon.stop() + + self.tray_icon = None + + self.ui_queue.put( + ( + "show_window", + None, + ) + ) + + def exit_from_tray( + self, + icon: pystray.Icon, + menu_item: object, + ) -> None: + """Exit Documents Organizer from the system tray.""" + icon.stop() + + self.tray_icon = None + + self.ui_queue.put( + ( + "exit_application", + None, + ) + ) + + # ------------------------------------------------------------------------- + # Application commands + # ------------------------------------------------------------------------- + + def show_about(self) -> None: + """Display application information.""" + messagebox.showinfo( + "About Documents Organizer", + ( + "Documents Organizer\n" + f"Version: v{__version__}\n\n" + "Created by David Southwood\n" + "License: MIT License" + ), + ) + + def exit_app(self) -> None: + """Close the application.""" + if self.current_operation is not None: + should_exit = ( + messagebox.askyesno( + "Operation in Progress", + ( + "A file operation is still " + "running.\n\n" + "Are you sure you want to " + "exit Documents Organizer?" + ), + ) + ) + + if not should_exit: + return + + if ( + self.current_operation + == "flatten" + ): + self.flatten_cancel_event.set() + + if self.tray_icon is not None: + self.tray_icon.stop() + + self.tray_icon = None + + self.root.destroy() \ No newline at end of file diff --git a/main.py b/main.py index 798b8b7..fb27eeb 100644 --- a/main.py +++ b/main.py @@ -1,936 +1,5 @@ -import os -import threading -import tkinter as tk -import tkinter.scrolledtext as scrolledtext -from tkinter import filedialog, messagebox, simpledialog, ttk +from documents_organizer.app import run -import pystray -from PIL import Image -from pystray import MenuItem as item -from documents_organizer import __version__ -from documents_organizer.platform_utils import open_in_file_manager -from documents_organizer.services.organizer import ( - OrganizationResult, - organize_directory, -) -from documents_organizer.services.flattener import ( - FlattenResult, - flatten_directory, -) - -flatten_cancel_event = threading.Event() - -# Global variable to store the folder path -folder_path = "" - -def organize_files(selected_folder): - """Start an organization operation in a background thread.""" - log_to_text( - f"Organizing: {selected_folder}" - ) - - worker = threading.Thread( - target=_run_organizer, - args=(selected_folder,), - daemon=True, - ) - - worker.start() - - -def _run_organizer(selected_folder): - """Run the organizer service outside the Tkinter main thread.""" - try: - result = organize_directory( - selected_folder - ) - - except ( - FileNotFoundError, - NotADirectoryError, - PermissionError, - OSError, - ) as exc: - win.after( - 0, - _handle_organization_error, - str(exc), - ) - - return - - win.after( - 0, - _handle_organization_result, - result, - ) - - -def _handle_organization_result( - result: OrganizationResult, -): - """Display organization results on the Tkinter main thread.""" - for extension, count in sorted( - result.by_extension.items() - ): - label = ( - "file" - if count == 1 - else "files" - ) - - log_to_text( - f"Organized {count} " - f"{extension} {label}." - ) - - if result.skipped: - label = ( - "file" - if result.skipped == 1 - else "files" - ) - - log_to_text( - f"Skipped {result.skipped} " - f"{label}." - ) - - if result.failed: - label = ( - "file" - if result.failed == 1 - else "files" - ) - - log_to_text( - f"Unable to process " - f"{result.failed} {label}." - ) - - for failure in result.failures: - log_to_text( - f" {failure.path}: " - f"{failure.error}" - ) - - log_to_text( - f"Organization complete. " - f"{result.moved} files moved." - ) - - refresh_treeview() - - -def _handle_organization_error( - message: str, -): - """Display a fatal organization error.""" - log_to_text( - f"Organization failed: {message}" - ) - - messagebox.showerror( - "Organization Failed", - message, - ) - -def flatten_folders(): - """Start a flatten operation for the selected folder.""" - selected_item = tree.focus() - - if not selected_item: - messagebox.showerror( - "Error", - "Please select a folder first.", - ) - return - - selected_folder = get_full_path( - tree, - selected_item, - ) - - if not os.path.isdir( - selected_folder - ): - messagebox.showerror( - "Error", - "The selected folder does not exist.", - ) - return - - flatten_cancel_event.clear() - - log_to_text( - f"Flattening: {selected_folder}" - ) - - worker = threading.Thread( - target=_run_flattener, - args=(selected_folder,), - daemon=True, - ) - - worker.start() - -def _run_flattener( - selected_folder, -): - """Run the flattener service outside the Tkinter main thread.""" - try: - result = flatten_directory( - selected_folder, - cancel_event=flatten_cancel_event, - ) - - except ( - FileNotFoundError, - NotADirectoryError, - PermissionError, - OSError, - ) as exc: - win.after( - 0, - _handle_flatten_error, - str(exc), - ) - - return - - win.after( - 0, - _handle_flatten_result, - result, - ) - -def _handle_flatten_result( - result: FlattenResult, -): - """Display flatten results on the Tkinter main thread.""" - for extension, count in sorted( - result.by_extension.items() - ): - label = ( - "file" - if count == 1 - else "files" - ) - - log_to_text( - f"Flattened {count} " - f"{extension} {label}." - ) - - if result.skipped: - label = ( - "file" - if result.skipped == 1 - else "files" - ) - - log_to_text( - f"Skipped {result.skipped} " - f"{label} that did not match " - "their file-type folder." - ) - - if result.failed: - label = ( - "failure" - if result.failed == 1 - else "failures" - ) - - log_to_text( - f"Encountered " - f"{result.failed} {label}." - ) - - for failure in result.failures: - log_to_text( - f" {failure.path}: " - f"{failure.error}" - ) - - if result.cancelled: - log_to_text( - "Flattening canceled." - ) - else: - log_to_text( - f"Flattening complete. " - f"{result.moved} files moved " - f"and " - f"{result.directories_removed} " - f"empty folders removed." - ) - - refresh_treeview() - -def _handle_flatten_error( - message: str, -): - """Display a fatal flatten error.""" - log_to_text( - f"Flattening failed: {message}" - ) - - messagebox.showerror( - "Flattening Failed", - message, - ) - -def get_full_path(tree, item): - """Get the full path of a selected item in the Treeview.""" - path_components = [ - tree.item(item)["text"] - ] - - parent = tree.parent(item) - - while parent: - path_components.insert( - 0, - tree.item(parent)["text"], - ) - - parent = tree.parent(parent) - - return os.path.join( - *path_components - ) - - -def add_extensions(): - """Add extensions to the list of extensions to flatten.""" - existing_extensions = extensions_to_flatten - - new_extensions = input_extensions( - existing_extensions - ) - - extensions_to_flatten.extend( - new_extensions - ) - - log_to_text( - "Extensions to flatten:\n" - + ", ".join(extensions_to_flatten) - ) - - -def input_extensions(existing_extensions): - """Prompt user to input extensions to add.""" - extensions_str = simpledialog.askstring( - "Add Extensions", - ( - "Enter extensions separated by commas " - "(e.g., mp4, webp, exe, jpg):" - ), - ) - - if not extensions_str: - return [] - - new_extensions = [ - extension.strip().lower().lstrip(".") - for extension in extensions_str.split(",") - if extension.strip() - ] - - return list( - set(new_extensions) - - set(existing_extensions) - ) - - -def stop_flattening(): - """Request cancellation of the active flatten operation.""" - if flatten_cancel_event.is_set(): - return - - flatten_cancel_event.set() - - log_to_text( - "Cancel requested..." - ) - - -def exit_application(icon, menu_item): - """Exit the application from the system tray.""" - icon.stop() - - win.after( - 0, - win.destroy, - ) - - -def hide_window(): - """Hide the window and display a system tray icon.""" - win.withdraw() - - try: - image = Image.open( - "images/folder-256.png" - ) - - menu = ( - item( - "Show", - show_window, - ), - item( - "Quit", - exit_application, - ), - ) - - icon = pystray.Icon( - "DocumentsOrganizer", - image, - "Documents Organizer", - menu, - ) - - threading.Thread( - target=icon.run, - daemon=True, - ).start() - - except Exception as exc: - win.deiconify() - - messagebox.showerror( - "System Tray Error", - ( - "Documents Organizer could not " - f"start the system tray icon.\n\n{exc}" - ), - ) - - -def show_window(icon, menu_item): - """Show the application window again.""" - icon.stop() - - win.after( - 0, - win.deiconify, - ) - - -def select_folder(): - """Handle the Select Folder menu option.""" - global folder_path - - selected_folder = filedialog.askdirectory() - - if not selected_folder: - return - - folder_path = selected_folder - - update_treeview( - folder_path - ) - - -def run_organizer(): - """Handle the Organize Folders menu option.""" - selected_item = tree.focus() - - if not selected_item: - messagebox.showerror( - "Error", - "Please select a folder first.", - ) - return - - selected_folder = get_full_path( - tree, - selected_item, - ) - - if not os.path.isdir( - selected_folder - ): - messagebox.showerror( - "Error", - "The selected folder does not exist.", - ) - return - - organize_files( - selected_folder - ) - - -def exit_app(): - """Exit the application.""" - win.destroy() - - -def update_treeview(directory): - """Update the Treeview with the directory structure.""" - tree.delete( - *tree.get_children() - ) - - populate_tree( - tree, - directory, - ) - - -def populate_tree(tree, directory): - """Populate the Treeview with the directory structure.""" - root_node = tree.insert( - "", - "end", - text=directory, - ) - - populate_children( - tree, - root_node, - directory, - ) - - -def populate_children(tree, parent, directory): - """Populate children of a node in the Treeview.""" - try: - items = os.listdir(directory) - except (PermissionError, FileNotFoundError): - return - - for item_name in items: - item_path = os.path.join( - directory, - item_name, - ) - - if os.path.isdir(item_path): - node = tree.insert( - parent, - "end", - text=item_name, - ) - - populate_subdirectories( - tree, - node, - item_path, - ) - - -def populate_subdirectories( - tree, - parent, - directory, -): - """Populate subdirectories of a node in the Treeview.""" - try: - items = os.listdir(directory) - except (PermissionError, FileNotFoundError): - return - - for item_name in items: - item_path = os.path.join( - directory, - item_name, - ) - - if os.path.isdir(item_path): - node = tree.insert( - parent, - "end", - text=item_name, - ) - - populate_subdirectories( - tree, - node, - item_path, - ) - - -def refresh_treeview(): - """Refresh the Treeview after folder operations.""" - global folder_path - - if not folder_path: - return - - if not os.path.isdir(folder_path): - return - - tree.delete( - *tree.get_children() - ) - - update_treeview( - folder_path - ) - - -def scroll_to_end(): - """Scroll to the end of the log.""" - log_text.see( - tk.END - ) - - -def start_application(): - """Display application startup information.""" - log_to_text( - "Extensions to flatten:\n" - + ", ".join(extensions_to_flatten) - ) - - -def log_to_text(message): - """Log a message to the text widget.""" - log_text.config( - state=tk.NORMAL - ) - - log_text.insert( - tk.END, - message + "\n", - ) - - log_text.config( - state=tk.DISABLED - ) - - scroll_to_end() - - -def clear_log(): - """Clear the log.""" - log_text.config( - state=tk.NORMAL - ) - - log_text.delete( - "1.0", - tk.END, - ) - - log_text.config( - state=tk.DISABLED - ) - - start_application() - - -def open_selected_folder(): - """Open the selected folder in the operating system's file manager.""" - selected_items = tree.selection() - - if not selected_items: - messagebox.showerror( - "Error", - "Please select a folder first.", - ) - return - - selected_item = selected_items[0] - - selected_path = get_full_path( - tree, - selected_item, - ) - - try: - open_in_file_manager( - selected_path - ) - - except ( - FileNotFoundError, - NotADirectoryError, - OSError, - ) as exc: - messagebox.showerror( - "Unable to Open Folder", - str(exc), - ) - - -def popup_menu(event): - """Display the folder context menu.""" - selected_item = tree.identify_row( - event.y - ) - - if not selected_item: - return - - tree.selection_set( - selected_item - ) - - tree.focus( - selected_item - ) - - popup = tk.Menu( - win, - tearoff=0, - ) - - popup.add_command( - label="Open in File Manager", - command=open_selected_folder, - ) - - popup.post( - event.x_root, - event.y_root, - ) - - -def show_about(): - """Display information about the application.""" - messagebox.showinfo( - "About", - ( - "Documents Organizer\n" - f"Version: v{__version__}\n" - "Created by: David Southwood\n" - "License: MIT License" - ), - ) - - -extensions_to_flatten = [ - "ini", - "zip", - "mp4", - "pdf", - "cpp", - "rar", - "jpg", - "save", - "h", - "txt", - "doc", - "bin", - "exe", - "jar", - "png", - "tmp", - "docx", - "webp", - "mm", -] - - -win = tk.Tk() - -win.title( - "Documents Organizer" -) - -try: - win.iconbitmap( - "images/folder-256.ico" - ) -except tk.TclError: - pass - -win.geometry( - "1080x800" -) - - -# Menu bar -menu_bar = tk.Menu( - win -) - -win.config( - menu=menu_bar -) - - -# File menu -file_menu = tk.Menu( - menu_bar, - tearoff=0, -) - -file_menu.add_command( - label="Select Folder", - command=select_folder, -) - -file_menu.add_separator() - -file_menu.add_command( - label="Exit", - command=exit_app, -) - -menu_bar.add_cascade( - label="File", - menu=file_menu, -) - - -# Action menu -action_menu = tk.Menu( - menu_bar, - tearoff=0, -) - -organize_submenu = tk.Menu( - action_menu, - tearoff=0, -) - -organize_submenu.add_command( - label="Organize Folders", - command=run_organizer, -) - -organize_submenu.add_command( - label="Flatten Folders", - command=flatten_folders, -) - -organize_submenu.add_command( - label="Cancel Flatten Folders", - command=stop_flattening, -) - -action_menu.add_cascade( - label="Organize", - menu=organize_submenu, -) - -action_menu.add_command( - label="Add Extensions", - command=add_extensions, -) - -view_submenu = tk.Menu( - action_menu, - tearoff=0, -) - -view_submenu.add_command( - label="Clear Log", - command=clear_log, -) - -view_submenu.add_command( - label="Refresh TreeView", - command=refresh_treeview, -) - -action_menu.add_cascade( - label="View", - menu=view_submenu, -) - -menu_bar.add_cascade( - label="Action", - menu=action_menu, -) - - -# Help menu -help_menu = tk.Menu( - menu_bar, - tearoff=0, -) - -help_menu.add_command( - label="About", - command=show_about, -) - -menu_bar.add_cascade( - label="Help", - menu=help_menu, -) - - -# Treeview -tree_frame = tk.Frame( - win -) - -tree_frame.pack( - side=tk.LEFT, - fill=tk.BOTH, - expand=True, -) - -tree = ttk.Treeview( - tree_frame -) - -tree.pack( - expand=tk.YES, - fill=tk.BOTH, - padx=5, - pady=5, -) - -tree.bind( - "", - popup_menu, -) - -ttk.Sizegrip( - tree_frame -).pack( - side="right", - fill="y", -) - - -# Log -log_frame = tk.Frame( - win, - width=500, -) - -log_frame.pack( - side=tk.RIGHT, - fill=tk.BOTH, - expand=True, -) - -log_text = scrolledtext.ScrolledText( - log_frame, - height=10, - width=50, -) - -log_text.pack( - expand=tk.YES, - fill=tk.BOTH, -) - -log_text.config( - state=tk.DISABLED -) - - -win.protocol( - "WM_DELETE_WINDOW", - hide_window, -) - - -start_application() - -win.mainloop() \ No newline at end of file +if __name__ == "__main__": + run() \ No newline at end of file From 3359426d35c77150c83e29e624963d067d4c0b2d Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 23:05:32 -0400 Subject: [PATCH 06/20] Refactor application structure and UI services - move Tkinter application logic into MainWindow - separate organizer and flattener business services - add centralized application settings - add resource path handling for future packaging - centralize reusable application dialogs - use thread-safe queue for worker-to-UI communication - prevent conflicting file operations - improve system tray and shutdown handling --- documents_organizer/resources.py | 45 ++++++++++ documents_organizer/settings.py | 17 ++++ documents_organizer/ui/dialogs.py | 64 +++++++++++++ documents_organizer/ui/main_window.py | 124 ++++++++++++++++---------- 4 files changed, 201 insertions(+), 49 deletions(-) diff --git a/documents_organizer/resources.py b/documents_organizer/resources.py index e69de29..0e77c64 100644 --- a/documents_organizer/resources.py +++ b/documents_organizer/resources.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +def get_resource_path(*parts: str) -> Path: + """ + Return the absolute path to an application resource. + + During normal development, resources are resolved relative to the + repository root. + + When packaged with PyInstaller, resources are resolved relative to + PyInstaller's temporary extraction directory. + """ + if getattr(sys, "frozen", False): + base_path = Path( + getattr( + sys, + "_MEIPASS", + Path(sys.executable).parent, + ) + ) + else: + base_path = ( + Path(__file__) + .resolve() + .parent + .parent + ) + + return base_path.joinpath( + *parts + ) + + +def get_image_path( + filename: str, +) -> Path: + """Return the absolute path to an image resource.""" + return get_resource_path( + "images", + filename, + ) \ No newline at end of file diff --git a/documents_organizer/settings.py b/documents_organizer/settings.py index e69de29..873ef48 100644 --- a/documents_organizer/settings.py +++ b/documents_organizer/settings.py @@ -0,0 +1,17 @@ +"""Application-wide settings for Documents Organizer.""" + + +APP_NAME = "Documents Organizer" + +DEFAULT_WINDOW_WIDTH = 1080 +DEFAULT_WINDOW_HEIGHT = 800 + +MIN_WINDOW_WIDTH = 800 +MIN_WINDOW_HEIGHT = 600 + +UI_QUEUE_POLL_INTERVAL_MS = 50 + +TRAY_ICON_NAME = "DocumentsOrganizer" + +WINDOW_ICON_FILE = "folder-256.ico" +TRAY_ICON_FILE = "folder-256.png" \ No newline at end of file diff --git a/documents_organizer/ui/dialogs.py b/documents_organizer/ui/dialogs.py index e69de29..7139782 100644 --- a/documents_organizer/ui/dialogs.py +++ b/documents_organizer/ui/dialogs.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox + +from documents_organizer import __version__ +from documents_organizer.settings import APP_NAME + + +def show_error( + parent: tk.Misc, + title: str, + message: str, +) -> None: + """Display an application error dialog.""" + messagebox.showerror( + title, + message, + parent=parent, + ) + + +def show_warning( + parent: tk.Misc, + title: str, + message: str, +) -> None: + """Display an application warning dialog.""" + messagebox.showwarning( + title, + message, + parent=parent, + ) + + +def ask_confirmation( + parent: tk.Misc, + title: str, + message: str, +) -> bool: + """Display a yes/no confirmation dialog.""" + return bool( + messagebox.askyesno( + title, + message, + parent=parent, + ) + ) + + +def show_about( + parent: tk.Misc, +) -> None: + """Display information about Documents Organizer.""" + messagebox.showinfo( + f"About {APP_NAME}", + ( + f"{APP_NAME}\n" + f"Version: v{__version__}\n\n" + "Created by David Southwood\n" + "License: MIT License" + ), + parent=parent, + ) \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index 0980c5c..2c9bfa7 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -6,7 +6,7 @@ import tkinter as tk import tkinter.scrolledtext as scrolledtext from pathlib import Path -from tkinter import filedialog, messagebox, ttk +from tkinter import filedialog, ttk import pystray from PIL import Image @@ -14,6 +14,7 @@ from documents_organizer import __version__ from documents_organizer.platform_utils import open_in_file_manager +from documents_organizer.resources import get_image_path from documents_organizer.services.flattener import ( FlattenResult, flatten_directory, @@ -22,6 +23,23 @@ OrganizationResult, organize_directory, ) +from documents_organizer.settings import ( + APP_NAME, + DEFAULT_WINDOW_HEIGHT, + DEFAULT_WINDOW_WIDTH, + MIN_WINDOW_HEIGHT, + MIN_WINDOW_WIDTH, + TRAY_ICON_FILE, + TRAY_ICON_NAME, + UI_QUEUE_POLL_INTERVAL_MS, + WINDOW_ICON_FILE, +) +from documents_organizer.ui.dialogs import ( + ask_confirmation, + show_about as show_about_dialog, + show_error, + show_warning, +) class MainWindow: @@ -50,7 +68,7 @@ def __init__(self, root: tk.Tk) -> None: # Start polling for messages from background workers. self.root.after( - 50, + UI_QUEUE_POLL_INTERVAL_MS, self._process_ui_queue, ) @@ -61,24 +79,31 @@ def __init__(self, root: tk.Tk) -> None: def _configure_window(self) -> None: """Configure the root application window.""" self.root.title( - "Documents Organizer" + APP_NAME ) self.root.geometry( - "1080x800" + f"{DEFAULT_WINDOW_WIDTH}x" + f"{DEFAULT_WINDOW_HEIGHT}" ) self.root.minsize( - 800, - 600, + MIN_WINDOW_WIDTH, + MIN_WINDOW_HEIGHT, ) try: self.root.iconbitmap( - "images/folder-256.ico" + str( + get_image_path( + WINDOW_ICON_FILE + ) + ) ) - except tk.TclError: - # The ICO file may not be supported on every platform. + except ( + tk.TclError, + OSError, + ): pass def _bind_events(self) -> None: @@ -462,14 +487,16 @@ def run_organizer(self) -> None: ) if selected_folder is None: - messagebox.showerror( + show_error( + self.root, "No Folder Selected", "Please select a folder first.", ) return if not selected_folder.is_dir(): - messagebox.showerror( + show_error( + self.root, "Invalid Folder", "The selected folder does not exist.", ) @@ -592,8 +619,8 @@ def _handle_organization_error( f"Organization failed: " f"{message}" ) - - messagebox.showerror( + show_error( + self.root, "Organization Failed", message, ) @@ -609,14 +636,16 @@ def run_flattener(self) -> None: ) if selected_folder is None: - messagebox.showerror( + show_error( + self.root, "No Folder Selected", "Please select a folder first.", ) return if not selected_folder.is_dir(): - messagebox.showerror( + show_error( + self.root, "Invalid Folder", "The selected folder does not exist.", ) @@ -756,7 +785,8 @@ def _handle_flatten_error( f"{message}" ) - messagebox.showerror( + show_error( + self.root, "Flattening Failed", message, ) @@ -788,7 +818,8 @@ def _begin_operation( ) -> bool: """Prevent conflicting filesystem operations from running together.""" if self.current_operation is not None: - messagebox.showwarning( + show_warning( + self.root, "Operation in Progress", ( "Another file operation is " @@ -875,7 +906,7 @@ def _process_ui_queue(self) -> None: pass self.root.after( - 50, + UI_QUEUE_POLL_INTERVAL_MS, self._process_ui_queue, ) @@ -886,8 +917,7 @@ def _process_ui_queue(self) -> None: def _log_startup_message(self) -> None: """Display initial application information.""" self.log_to_text( - f"Documents Organizer " - f"v{__version__}" + f"{APP_NAME} v{__version__}" ) self.log_to_text( @@ -981,7 +1011,8 @@ def open_selected_folder(self) -> None: ) if selected_folder is None: - messagebox.showerror( + show_error( + self.root, "No Folder Selected", "Please select a folder first.", ) @@ -997,7 +1028,8 @@ def open_selected_folder(self) -> None: NotADirectoryError, OSError, ) as exc: - messagebox.showerror( + show_error( + self.root, "Unable to Open Folder", str(exc), ) @@ -1015,7 +1047,9 @@ def hide_window(self) -> None: try: image = Image.open( - "images/folder-256.png" + get_image_path( + TRAY_ICON_FILE + ) ) tray_menu = ( @@ -1030,9 +1064,9 @@ def hide_window(self) -> None: ) self.tray_icon = pystray.Icon( - "DocumentsOrganizer", + TRAY_ICON_NAME, image, - "Documents Organizer", + APP_NAME, tray_menu, ) @@ -1045,14 +1079,14 @@ def hide_window(self) -> None: self.tray_icon = None self.root.deiconify() - - messagebox.showerror( + show_error( + self.root, "System Tray Error", ( "Documents Organizer could " "not create the system tray " f"icon.\n\n{exc}" - ), + ) ) def show_window( @@ -1095,43 +1129,35 @@ def exit_from_tray( def show_about(self) -> None: """Display application information.""" - messagebox.showinfo( - "About Documents Organizer", - ( - "Documents Organizer\n" - f"Version: v{__version__}\n\n" - "Created by David Southwood\n" - "License: MIT License" - ), + show_about_dialog( + self.root ) def exit_app(self) -> None: """Close the application.""" if self.current_operation is not None: - should_exit = ( - messagebox.askyesno( - "Operation in Progress", - ( - "A file operation is still " - "running.\n\n" - "Are you sure you want to " - "exit Documents Organizer?" - ), - ) + should_exit = ask_confirmation( + self.root, + "Operation in Progress", + ( + "A file operation is still " + "running.\n\n" + f"Are you sure you want to exit " + f"{APP_NAME}?" + ), ) if not should_exit: return if ( - self.current_operation - == "flatten" + self.current_operation + == "flatten" ): self.flatten_cancel_event.set() if self.tray_icon is not None: self.tray_icon.stop() - self.tray_icon = None self.root.destroy() \ No newline at end of file From 37bad521ca5dca758d61031db7daa9318bd6550d Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Tue, 25 Aug 2026 23:42:22 -0400 Subject: [PATCH 07/20] Redesign main application interface - add toolbar for common file operations - add selected root and operation target display - add folder browser and activity log panels - add status bar and operation progress indicator - improve action enable/disable states - add explicit minimize-to-tray behavior - make window close exit the application normally - improve activity logging and empty states - refine pane layout and desktop styling --- documents_organizer/ui/main_window.py | 1120 ++++++++++++++++++++++--- 1 file changed, 983 insertions(+), 137 deletions(-) diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index 2c9bfa7..30def1b 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -5,6 +5,7 @@ import threading import tkinter as tk import tkinter.scrolledtext as scrolledtext +from datetime import datetime from pathlib import Path from tkinter import filedialog, ttk @@ -35,7 +36,6 @@ WINDOW_ICON_FILE, ) from documents_organizer.ui.dialogs import ( - ask_confirmation, show_about as show_about_dialog, show_error, show_warning, @@ -51,22 +51,36 @@ def __init__(self, root: tk.Tk) -> None: self.folder_path: Path | None = None self.flatten_cancel_event = threading.Event() self.current_operation: str | None = None + self.is_closing = False self.tray_icon: pystray.Icon | None = None # Worker threads communicate with Tkinter through this queue. - # Tkinter itself is only updated from the main thread. self.ui_queue: queue.Queue[ tuple[str, object] ] = queue.Queue() + # UI state. + self.root_folder_var = tk.StringVar( + value="No folder selected" + ) + + self.target_folder_var = tk.StringVar( + value="No folder selected" + ) + + self.status_var = tk.StringVar( + value="Ready" + ) + self._configure_window() + self._configure_styles() self._create_menu_bar() self._create_layout() self._bind_events() self._log_startup_message() + self._update_action_states() - # Start polling for messages from background workers. self.root.after( UI_QUEUE_POLL_INTERVAL_MS, self._process_ui_queue, @@ -100,21 +114,108 @@ def _configure_window(self) -> None: ) ) ) + except ( - tk.TclError, - OSError, + tk.TclError, + OSError, ): pass + def _configure_styles(self) -> None: + """Configure ttk styles used by the application.""" + style = ttk.Style( + self.root + ) + + style.configure( + "AppTitle.TLabel", + font=( + "Segoe UI", + 18, + "bold", + ), + ) + + style.configure( + "AppSubtitle.TLabel", + font=( + "Segoe UI", + 10, + ), + ) + + style.configure( + "Version.TLabel", + font=( + "Segoe UI", + 9, + ), + ) + + style.configure( + "Toolbar.TButton", + padding=( + 10, + 7, + ), + ) + + style.configure( + "Primary.TButton", + padding=( + 12, + 7, + ), + ) + + style.configure( + "Section.TLabelframe", + padding=10, + ) + + style.configure( + "Section.TLabelframe.Label", + font=( + "Segoe UI", + 10, + "bold", + ), + ) + + style.configure( + "PathLabel.TLabel", + font=( + "Segoe UI", + 9, + ), + ) + + style.configure( + "Status.TLabel", + padding=( + 4, + 2, + ), + ) + + style.configure( + "Treeview", + rowheight=26, + font=( + "Segoe UI", + 10, + ), + ) + def _bind_events(self) -> None: - """Bind application-level window events.""" + """Bind application-level events.""" self.root.protocol( "WM_DELETE_WINDOW", - self.hide_window, + self.exit_app, ) # ------------------------------------------------------------------------- - # Menus + # Menu bar # ------------------------------------------------------------------------- def _create_menu_bar(self) -> None: @@ -127,80 +228,87 @@ def _create_menu_bar(self) -> None: menu=menu_bar ) - # File - file_menu = tk.Menu( + # File menu + self.file_menu = tk.Menu( menu_bar, tearoff=0, ) - file_menu.add_command( + self.file_menu.add_command( label="Select Folder", command=self.select_folder, ) - file_menu.add_separator() + self.file_menu.add_separator() + + self.file_menu.add_command( + label="Minimize to Tray", + command=self.hide_window, + ) + + self.file_menu.add_separator() - file_menu.add_command( + self.file_menu.add_command( label="Exit", command=self.exit_app, ) menu_bar.add_cascade( label="File", - menu=file_menu, + menu=self.file_menu, ) - # Actions + # Action menu action_menu = tk.Menu( menu_bar, tearoff=0, ) - organize_menu = tk.Menu( + self.organize_menu = tk.Menu( action_menu, tearoff=0, ) - organize_menu.add_command( + self.organize_menu.add_command( label="Organize Files", command=self.run_organizer, ) - organize_menu.add_command( + self.organize_menu.add_command( label="Flatten Files", command=self.run_flattener, ) - organize_menu.add_separator() + self.organize_menu.add_separator() - organize_menu.add_command( + self.organize_menu.add_command( label="Cancel Flatten Operation", command=self.stop_flattening, ) action_menu.add_cascade( label="Organize", - menu=organize_menu, + menu=self.organize_menu, ) - view_menu = tk.Menu( + self.view_menu = tk.Menu( action_menu, tearoff=0, ) - view_menu.add_command( - label="Clear Log", + self.view_menu.add_command( + label="Clear Activity Log", command=self.clear_log, ) - view_menu.add_command( + self.view_menu.add_command( label="Refresh Folder Tree", command=self.refresh_treeview, ) action_menu.add_cascade( label="View", - menu=view_menu, + menu=self.view_menu, ) menu_bar.add_cascade( @@ -208,7 +316,7 @@ def _create_menu_bar(self) -> None: menu=action_menu, ) - # Help + # Help menu help_menu = tk.Menu( menu_bar, tearoff=0, @@ -230,43 +338,357 @@ def _create_menu_bar(self) -> None: def _create_layout(self) -> None: """Create the primary application layout.""" + self.root.rowconfigure( + 0, + weight=1, + ) + + self.root.columnconfigure( + 0, + weight=1, + ) + self.main_frame = ttk.Frame( - self.root + self.root, + padding=( + 16, + 14, + ), + ) + + self.main_frame.grid( + row=0, + column=0, + sticky="nsew", + ) + + self.main_frame.columnconfigure( + 0, + weight=1, + ) + + self.main_frame.rowconfigure( + 3, + weight=1, + ) + + self._create_header() + self._create_toolbar() + self._create_folder_summary() + self._create_workspace() + self._create_status_bar() + + def _create_header(self) -> None: + """Create the application header.""" + header = ttk.Frame( + self.main_frame + ) + + header.grid( + row=0, + column=0, + sticky="ew", + pady=( + 0, + 12, + ), + ) + + header.columnconfigure( + 0, + weight=1, + ) + + title = ttk.Label( + header, + text=APP_NAME, + style="AppTitle.TLabel", + ) + + title.grid( + row=0, + column=0, + sticky="w", + ) + + subtitle = ttk.Label( + header, + text=( + "Organize files by modified date " + "and file type." + ), + style="AppSubtitle.TLabel", + ) + + subtitle.grid( + row=1, + column=0, + sticky="w", + pady=( + 2, + 0, + ), + ) + + def _create_toolbar(self) -> None: + """Create the main action toolbar.""" + toolbar = ttk.Frame( + self.main_frame + ) + + toolbar.grid( + row=1, + column=0, + sticky="ew", + pady=( + 0, + 12, + ), + ) + + # Flexible space between file operations and utility actions. + toolbar.columnconfigure( + 5, + weight=1, + ) + + self.select_folder_button = ttk.Button( + toolbar, + text="Select Folder", + command=self.select_folder, + style="Primary.TButton", + ) + + self.select_folder_button.grid( + row=0, + column=0, + padx=( + 0, + 6, + ), + ) + + first_separator = ttk.Separator( + toolbar, + orient=tk.VERTICAL, + ) + + first_separator.grid( + row=0, + column=1, + sticky="ns", + padx=8, + ) + + self.organize_button = ttk.Button( + toolbar, + text="Organize", + command=self.run_organizer, + style="Toolbar.TButton", + ) + + self.organize_button.grid( + row=0, + column=2, + padx=6, + ) + + self.flatten_button = ttk.Button( + toolbar, + text="Flatten", + command=self.run_flattener, + style="Toolbar.TButton", + ) + + self.flatten_button.grid( + row=0, + column=3, + padx=6, + ) + + self.cancel_button = ttk.Button( + toolbar, + text="Cancel", + command=self.stop_flattening, + style="Toolbar.TButton", + ) + + self.cancel_button.grid( + row=0, + column=4, + padx=6, + ) + + second_separator = ttk.Separator( + toolbar, + orient=tk.VERTICAL, + ) + + second_separator.grid( + row=0, + column=6, + sticky="ns", + padx=8, + ) + + self.open_folder_button = ttk.Button( + toolbar, + text="Open Selected", + command=self.open_selected_folder, + style="Toolbar.TButton", + ) + + self.open_folder_button.grid( + row=0, + column=7, + padx=6, + ) + + self.refresh_button = ttk.Button( + toolbar, + text="Refresh", + command=self.refresh_treeview, + style="Toolbar.TButton", + ) + + self.refresh_button.grid( + row=0, + column=8, + padx=6, + ) + + self.clear_log_button = ttk.Button( + toolbar, + text="Clear Log", + command=self.clear_log, + style="Toolbar.TButton", + ) + + self.clear_log_button.grid( + row=0, + column=9, + padx=( + 6, + 0, + ), + ) + + def _create_folder_summary(self) -> None: + """Create the current-folder summary panel.""" + folder_frame = ttk.LabelFrame( + self.main_frame, + text="Selected Location", + style="Section.TLabelframe", + ) + + folder_frame.grid( + row=2, + column=0, + sticky="ew", + pady=( + 0, + 12, + ), + ) + + folder_frame.columnconfigure( + 1, + weight=1, + ) + + ttk.Label( + folder_frame, + text="Root Folder:", + ).grid( + row=0, + column=0, + sticky="nw", + padx=( + 0, + 10, + ), + pady=2, + ) + + ttk.Label( + folder_frame, + textvariable=self.root_folder_var, + style="PathLabel.TLabel", + ).grid( + row=0, + column=1, + sticky="ew", + pady=2, + ) + + ttk.Label( + folder_frame, + text="Operation Target:", + ).grid( + row=1, + column=0, + sticky="nw", + padx=( + 0, + 10, + ), + pady=2, ) - self.main_frame.pack( - fill=tk.BOTH, - expand=True, + ttk.Label( + folder_frame, + textvariable=self.target_folder_var, + style="PathLabel.TLabel", + ).grid( + row=1, + column=1, + sticky="ew", + pady=2, ) + def _create_workspace(self) -> None: + """Create the main folder-browser/activity workspace.""" self.paned_window = ttk.PanedWindow( self.main_frame, orient=tk.HORIZONTAL, ) - self.paned_window.pack( - fill=tk.BOTH, - expand=True, - padx=8, - pady=8, + self.paned_window.grid( + row=3, + column=0, + sticky="nsew", ) self._create_tree_panel() self._create_log_panel() def _create_tree_panel(self) -> None: - """Create the directory tree panel.""" - tree_frame = ttk.Frame( - self.paned_window + """Create the folder browser panel.""" + tree_frame = ttk.LabelFrame( + self.paned_window, + text="Folder Browser", + style="Section.TLabelframe", ) self.paned_window.add( tree_frame, + weight=2, + ) + + tree_frame.rowconfigure( + 0, + weight=1, + ) + + tree_frame.columnconfigure( + 0, weight=1, ) self.tree = ttk.Treeview( - tree_frame + tree_frame, + show="tree", + selectmode="browse", ) tree_scrollbar = ttk.Scrollbar( @@ -275,19 +697,38 @@ def _create_tree_panel(self) -> None: command=self.tree.yview, ) + horizontal_scrollbar = ttk.Scrollbar( + tree_frame, + orient=tk.HORIZONTAL, + command=self.tree.xview, + ) + self.tree.configure( - yscrollcommand=tree_scrollbar.set + yscrollcommand=tree_scrollbar.set, + xscrollcommand=horizontal_scrollbar.set, ) - self.tree.pack( - side=tk.LEFT, - fill=tk.BOTH, - expand=True, + self.tree.grid( + row=0, + column=0, + sticky="nsew", ) - tree_scrollbar.pack( - side=tk.RIGHT, - fill=tk.Y, + tree_scrollbar.grid( + row=0, + column=1, + sticky="ns", + ) + + horizontal_scrollbar.grid( + row=1, + column=0, + sticky="ew", + ) + + self.tree.bind( + "<>", + self._on_tree_selection, ) self.tree.bind( @@ -295,14 +736,42 @@ def _create_tree_panel(self) -> None: self.popup_menu, ) + self.tree_empty_label = ttk.Label( + tree_frame, + text=( + "No folder selected\n\n" + "Choose Select Folder to begin." + ), + anchor="center", + justify="center", + ) + + self.tree_empty_label.place( + relx=0.5, + rely=0.5, + anchor="center", + ) + def _create_log_panel(self) -> None: """Create the activity log panel.""" - log_frame = ttk.Frame( - self.paned_window + log_frame = ttk.LabelFrame( + self.paned_window, + text="Activity Log", + style="Section.TLabelframe", ) self.paned_window.add( log_frame, + weight=3, + ) + + log_frame.rowconfigure( + 0, + weight=1, + ) + + log_frame.columnconfigure( + 0, weight=1, ) @@ -311,22 +780,116 @@ def _create_log_panel(self) -> None: log_frame, wrap=tk.WORD, state=tk.DISABLED, + font=( + "Consolas", + 10, + ), + padx=10, + pady=10, + relief=tk.SOLID, + borderwidth=1, ) ) - self.log_text.pack( - fill=tk.BOTH, - expand=True, + self.log_text.grid( + row=0, + column=0, + sticky="nsew", + ) + + def _create_status_bar(self) -> None: + """Create the bottom status bar.""" + separator = ttk.Separator( + self.main_frame, + orient=tk.HORIZONTAL, + ) + + separator.grid( + row=4, + column=0, + sticky="ew", + pady=( + 12, + 6, + ), + ) + + status_frame = ttk.Frame( + self.main_frame + ) + + status_frame.grid( + row=5, + column=0, + sticky="ew", + ) + + status_frame.columnconfigure( + 0, + weight=1, + ) + + status_label = ttk.Label( + status_frame, + textvariable=self.status_var, + style="Status.TLabel", + ) + + status_label.grid( + row=0, + column=0, + sticky="w", + ) + + self.progress_bar = ttk.Progressbar( + status_frame, + mode="indeterminate", + length=180, + ) + + self.progress_bar.grid( + row=0, + column=1, + sticky="e", + padx=( + 10, + 16, + ), + ) + + self.progress_bar.grid_remove() + + version_label = ttk.Label( + status_frame, + text=f"v{__version__}", + style="Version.TLabel", + ) + + version_label.grid( + row=0, + column=2, + sticky="e", ) # ------------------------------------------------------------------------- - # Folder selection and tree + # Folder selection and browser # ------------------------------------------------------------------------- def select_folder(self) -> None: - """Allow the user to select a directory.""" - selected_folder = ( - filedialog.askdirectory() + """Allow the user to select a root directory.""" + if self.current_operation is not None: + show_warning( + self.root, + "Operation in Progress", + ( + "Please wait for the current " + "file operation to finish." + ), + ) + return + + selected_folder = filedialog.askdirectory( + title="Select Folder to Organize", ) if not selected_folder: @@ -336,6 +899,14 @@ def select_folder(self) -> None: selected_folder ).resolve() + self.root_folder_var.set( + str(self.folder_path) + ) + + self.target_folder_var.set( + str(self.folder_path) + ) + self.update_treeview( self.folder_path ) @@ -345,28 +916,57 @@ def select_folder(self) -> None: f"{self.folder_path}" ) + self.set_status( + "Folder selected." + ) + + self._update_action_states() + def update_treeview( self, directory: Path | str, ) -> None: """Update the folder tree with a directory structure.""" + directory = Path( + directory + ).resolve() + + self.tree_empty_label.place_forget() + self.tree.delete( *self.tree.get_children() ) - self.populate_tree( - Path(directory) + root_node = self.populate_tree( + directory + ) + + self.tree.selection_set( + root_node + ) + + self.tree.focus( + root_node + ) + + self.tree.see( + root_node + ) + + self.target_folder_var.set( + str(directory) ) def populate_tree( self, directory: Path, - ) -> None: - """Populate the root folder in the Treeview.""" + ) -> str: + """Populate the root directory in the Treeview.""" root_node = self.tree.insert( "", "end", text=str(directory), + open=True, ) self.populate_children( @@ -374,6 +974,8 @@ def populate_tree( directory, ) + return root_node + def populate_children( self, parent: str, @@ -383,7 +985,9 @@ def populate_children( try: items = sorted( directory.iterdir(), - key=lambda path: path.name.lower(), + key=lambda path: ( + path.name.lower() + ), ) except ( @@ -409,21 +1013,53 @@ def populate_children( ) def refresh_treeview(self) -> None: - """Refresh the displayed directory tree.""" + """Refresh the displayed folder tree.""" if self.folder_path is None: return if not self.folder_path.is_dir(): + show_error( + self.root, + "Folder Unavailable", + ( + "The selected root folder " + "no longer exists." + ), + ) return self.update_treeview( self.folder_path ) + self.set_status( + "Folder tree refreshed." + ) + + def _on_tree_selection( + self, + event: tk.Event | None = None, + ) -> None: + """Update the displayed operation target.""" + selected_folder = ( + self.get_selected_folder() + ) + + if selected_folder is None: + self.target_folder_var.set( + "No folder selected" + ) + else: + self.target_folder_var.set( + str(selected_folder) + ) + + self._update_action_states() + def get_selected_folder( self, ) -> Path | None: - """Return the full path for the currently selected tree item.""" + """Return the path represented by the selected Treeview item.""" selected_items = ( self.tree.selection() ) @@ -436,7 +1072,9 @@ def get_selected_folder( if not focused_item: return None - selected_item = focused_item + selected_item = ( + focused_item + ) else: selected_item = ( @@ -453,7 +1091,9 @@ def get_full_path( ) -> Path: """Build the filesystem path represented by a Treeview item.""" path_components = [ - self.tree.item(item)["text"] + self.tree.item( + item + )["text"] ] parent = self.tree.parent( @@ -463,7 +1103,9 @@ def get_full_path( while parent: path_components.insert( 0, - self.tree.item(parent)["text"], + self.tree.item( + parent + )["text"], ) parent = self.tree.parent( @@ -498,7 +1140,10 @@ def run_organizer(self) -> None: show_error( self.root, "Invalid Folder", - "The selected folder does not exist.", + ( + "The selected folder does " + "not exist." + ), ) return @@ -508,12 +1153,15 @@ def run_organizer(self) -> None: return self.log_to_text( - f"Organizing: {selected_folder}" + f"Organizing: " + f"{selected_folder}" ) worker = threading.Thread( target=self._run_organizer_worker, - args=(selected_folder,), + args=( + selected_folder, + ), daemon=True, ) @@ -523,7 +1171,7 @@ def _run_organizer_worker( self, selected_folder: Path, ) -> None: - """Run the organizer service on a background thread.""" + """Run the organizer service on a worker thread.""" try: result = organize_directory( selected_folder @@ -604,21 +1252,29 @@ def _handle_organization_result( f"{result.moved} files moved." ) - self.current_operation = None - self.refresh_treeview() + self._finish_operation( + ( + "Organization complete — " + f"{result.moved} files moved." + ) + ) + def _handle_organization_error( self, message: str, ) -> None: """Display a fatal organizer error.""" - self.current_operation = None - self.log_to_text( f"Organization failed: " f"{message}" ) + + self._finish_operation( + "Organization failed." + ) + show_error( self.root, "Organization Failed", @@ -647,7 +1303,10 @@ def run_flattener(self) -> None: show_error( self.root, "Invalid Folder", - "The selected folder does not exist.", + ( + "The selected folder does " + "not exist." + ), ) return @@ -659,12 +1318,15 @@ def run_flattener(self) -> None: self.flatten_cancel_event.clear() self.log_to_text( - f"Flattening: {selected_folder}" + f"Flattening: " + f"{selected_folder}" ) worker = threading.Thread( target=self._run_flattener_worker, - args=(selected_folder,), + args=( + selected_folder, + ), daemon=True, ) @@ -674,7 +1336,7 @@ def _run_flattener_worker( self, selected_folder: Path, ) -> None: - """Run the flattener service on a background thread.""" + """Run the flattener service on a worker thread.""" try: result = flatten_directory( selected_folder, @@ -760,6 +1422,8 @@ def _handle_flatten_result( "Flattening canceled." ) + status = "Flattening canceled." + else: self.log_to_text( f"Flattening complete. " @@ -769,22 +1433,31 @@ def _handle_flatten_result( f"empty folders removed." ) - self.current_operation = None + status = ( + "Flattening complete — " + f"{result.moved} files moved." + ) self.refresh_treeview() + self._finish_operation( + status + ) + def _handle_flatten_error( self, message: str, ) -> None: """Display a fatal flattener error.""" - self.current_operation = None - self.log_to_text( f"Flattening failed: " f"{message}" ) + self._finish_operation( + "Flattening failed." + ) + show_error( self.root, "Flattening Failed", @@ -792,14 +1465,16 @@ def _handle_flatten_error( ) def stop_flattening(self) -> None: - """Request cancellation of the active flatten operation.""" - if self.current_operation != "flatten": - self.log_to_text( - "No flatten operation is currently running." - ) + """Request cancellation of an active flatten operation.""" + if ( + self.current_operation + != "flatten" + ): return - if self.flatten_cancel_event.is_set(): + if ( + self.flatten_cancel_event.is_set() + ): return self.flatten_cancel_event.set() @@ -808,6 +1483,12 @@ def stop_flattening(self) -> None: "Cancel requested..." ) + self.set_status( + "Canceling flatten operation..." + ) + + self._update_action_states() + # ------------------------------------------------------------------------- # Operation state # ------------------------------------------------------------------------- @@ -816,8 +1497,11 @@ def _begin_operation( self, operation: str, ) -> bool: - """Prevent conflicting filesystem operations from running together.""" - if self.current_operation is not None: + """Start an application file operation.""" + if ( + self.current_operation + is not None + ): show_warning( self.root, "Operation in Progress", @@ -827,15 +1511,147 @@ def _begin_operation( "for it to finish." ), ) - return False self.current_operation = ( operation ) + if operation == "organize": + self.set_status( + "Organizing files..." + ) + + elif operation == "flatten": + self.set_status( + "Flattening files..." + ) + + self.progress_bar.grid() + + self.progress_bar.start( + 12 + ) + + self._update_action_states() + return True + def _finish_operation( + self, + status: str, + ) -> None: + """Finish the active application file operation.""" + self.current_operation = None + + self.progress_bar.stop() + + self.progress_bar.grid_remove() + + self.set_status( + status + ) + + self._update_action_states() + + def _update_action_states(self) -> None: + """Enable or disable commands based on application state.""" + folder_available = ( + self.folder_path is not None + and self.folder_path.is_dir() + ) + + busy = ( + self.current_operation + is not None + ) + + flattening = ( + self.current_operation + == "flatten" + ) + + cancel_available = ( + flattening + and not self.flatten_cancel_event.is_set() + ) + + select_state = ( + tk.DISABLED + if busy + else tk.NORMAL + ) + + operation_state = ( + tk.NORMAL + if folder_available + and not busy + else tk.DISABLED + ) + + utility_state = ( + tk.NORMAL + if folder_available + and not busy + else tk.DISABLED + ) + + cancel_state = ( + tk.NORMAL + if cancel_available + else tk.DISABLED + ) + + self.select_folder_button.configure( + state=select_state + ) + + self.organize_button.configure( + state=operation_state + ) + + self.flatten_button.configure( + state=operation_state + ) + + self.cancel_button.configure( + state=cancel_state + ) + + self.open_folder_button.configure( + state=utility_state + ) + + self.refresh_button.configure( + state=utility_state + ) + + # Menus + self.file_menu.entryconfig( + 0, + state=select_state, + ) + + self.organize_menu.entryconfig( + 0, + state=operation_state, + ) + + self.organize_menu.entryconfig( + 1, + state=operation_state, + ) + + self.organize_menu.entryconfig( + 3, + state=cancel_state, + ) + + self.view_menu.entryconfig( + 1, + state=utility_state, + ) + # ------------------------------------------------------------------------- # Worker → UI communication # ------------------------------------------------------------------------- @@ -844,8 +1660,7 @@ def _process_ui_queue(self) -> None: """ Process messages from worker threads. - This method runs on the Tkinter main thread so worker threads never - directly modify Tkinter widgets. + Only the Tkinter main thread updates widgets. """ try: while True: @@ -856,6 +1671,10 @@ def _process_ui_queue(self) -> None: if ( event_name == "organization_result" + and isinstance( + payload, + OrganizationResult, + ) ): self._handle_organization_result( payload @@ -872,6 +1691,10 @@ def _process_ui_queue(self) -> None: elif ( event_name == "flatten_result" + and isinstance( + payload, + FlattenResult, + ) ): self._handle_flatten_result( payload @@ -884,23 +1707,23 @@ def _process_ui_queue(self) -> None: self._handle_flatten_error( str(payload) ) + elif ( - event_name - == "show_window" + event_name + == "show_window" ): self.root.deiconify() - self.root.lift() - self.root.focus_force() elif ( - event_name - == "exit_application" + event_name + == "exit_application" ): - self.root.destroy() + self.root.deiconify() + self.root.lift() - return + self.exit_app() except queue.Empty: pass @@ -911,34 +1734,52 @@ def _process_ui_queue(self) -> None: ) # ------------------------------------------------------------------------- - # Activity log + # Status and activity log # ------------------------------------------------------------------------- + def set_status( + self, + message: str, + ) -> None: + """Update the application status message.""" + self.status_var.set( + message + ) + def _log_startup_message(self) -> None: """Display initial application information.""" self.log_to_text( - f"{APP_NAME} v{__version__}" + f"{APP_NAME} v{__version__} started." ) self.log_to_text( - "Ready." + "Select a folder to begin." ) def log_to_text( self, message: str, ) -> None: - """Append a message to the activity log.""" - self.log_text.config( + """Append a timestamped message to the activity log.""" + timestamp = ( + datetime.now().strftime( + "%H:%M:%S" + ) + ) + + self.log_text.configure( state=tk.NORMAL ) self.log_text.insert( tk.END, - message + "\n", + ( + f"[{timestamp}] " + f"{message}\n" + ), ) - self.log_text.config( + self.log_text.configure( state=tk.DISABLED ) @@ -948,7 +1789,7 @@ def log_to_text( def clear_log(self) -> None: """Clear the activity log.""" - self.log_text.config( + self.log_text.configure( state=tk.NORMAL ) @@ -957,14 +1798,16 @@ def clear_log(self) -> None: tk.END, ) - self.log_text.config( + self.log_text.configure( state=tk.DISABLED ) - self._log_startup_message() + self.log_to_text( + "Activity log cleared." + ) # ------------------------------------------------------------------------- - # File manager context menu + # File manager # ------------------------------------------------------------------------- def popup_menu( @@ -1005,7 +1848,7 @@ def popup_menu( ) def open_selected_folder(self) -> None: - """Open the selected folder using the platform file manager.""" + """Open the selected folder in the platform file manager.""" selected_folder = ( self.get_selected_folder() ) @@ -1039,18 +1882,24 @@ def open_selected_folder(self) -> None: # ------------------------------------------------------------------------- def hide_window(self) -> None: - """Hide the main window and create a system tray icon.""" + """Hide the application in the system tray.""" self.root.withdraw() - if self.tray_icon is not None: + if ( + self.tray_icon + is not None + ): return try: - image = Image.open( + with Image.open( get_image_path( TRAY_ICON_FILE ) - ) + ) as source_image: + image = ( + source_image.copy() + ) tray_menu = ( TrayMenuItem( @@ -1079,14 +1928,15 @@ def hide_window(self) -> None: self.tray_icon = None self.root.deiconify() + show_error( self.root, "System Tray Error", ( - "Documents Organizer could " - "not create the system tray " - f"icon.\n\n{exc}" - ) + f"{APP_NAME} could not create " + "the system tray icon.\n\n" + f"{exc}" + ), ) def show_window( @@ -1094,7 +1944,7 @@ def show_window( icon: pystray.Icon, menu_item: object, ) -> None: - """Restore the main application window from the tray.""" + """Restore the application from the system tray.""" icon.stop() self.tray_icon = None @@ -1111,7 +1961,7 @@ def exit_from_tray( icon: pystray.Icon, menu_item: object, ) -> None: - """Exit Documents Organizer from the system tray.""" + """Request application exit from the system tray.""" icon.stop() self.tray_icon = None @@ -1134,27 +1984,23 @@ def show_about(self) -> None: ) def exit_app(self) -> None: - """Close the application.""" + """Close the application safely.""" if self.current_operation is not None: - should_exit = ask_confirmation( + show_warning( self.root, "Operation in Progress", ( - "A file operation is still " - "running.\n\n" - f"Are you sure you want to exit " - f"{APP_NAME}?" + "Files are currently being " + "processed.\n\n" + "Please allow the operation " + "to finish, or cancel the " + "flatten operation before " + f"exiting {APP_NAME}." ), ) + return - if not should_exit: - return - - if ( - self.current_operation - == "flatten" - ): - self.flatten_cancel_event.set() + self.is_closing = True if self.tray_icon is not None: self.tray_icon.stop() From d4ef107d3923576874d60c06737141ef964caa9c Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 02:18:22 -0400 Subject: [PATCH 08/20] Extract reusable main window components - extract activity log into a reusable component - extract folder summary into a dedicated component - extract status bar and progress handling - extract main toolbar and button state handling - extract folder browser and tree management - simplify MainWindow UI responsibilities - preserve existing organize and flatten behavior --- documents_organizer/ui/components/__init__.py | 1 + .../ui/components/activity_log.py | 91 ++ .../ui/components/folder_browser.py | 494 ++++++++++ .../ui/components/folder_summary.py | 117 +++ documents_organizer/ui/components/menu_bar.py | 0 .../ui/components/status_bar.py | 92 ++ documents_organizer/ui/components/toolbar.py | 212 +++++ documents_organizer/ui/main_window.py | 899 ++++-------------- .../ui/operation_controller.py | 0 documents_organizer/ui/tray.py | 0 10 files changed, 1190 insertions(+), 716 deletions(-) create mode 100644 documents_organizer/ui/components/__init__.py create mode 100644 documents_organizer/ui/components/activity_log.py create mode 100644 documents_organizer/ui/components/folder_browser.py create mode 100644 documents_organizer/ui/components/folder_summary.py create mode 100644 documents_organizer/ui/components/menu_bar.py create mode 100644 documents_organizer/ui/components/status_bar.py create mode 100644 documents_organizer/ui/components/toolbar.py create mode 100644 documents_organizer/ui/operation_controller.py create mode 100644 documents_organizer/ui/tray.py diff --git a/documents_organizer/ui/components/__init__.py b/documents_organizer/ui/components/__init__.py new file mode 100644 index 0000000..30a0051 --- /dev/null +++ b/documents_organizer/ui/components/__init__.py @@ -0,0 +1 @@ +"""Reusable UI components for Documents Organizer.""" \ No newline at end of file diff --git a/documents_organizer/ui/components/activity_log.py b/documents_organizer/ui/components/activity_log.py new file mode 100644 index 0000000..3a6fc13 --- /dev/null +++ b/documents_organizer/ui/components/activity_log.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import tkinter as tk +import tkinter.scrolledtext as scrolledtext +from datetime import datetime +from tkinter import ttk + + +class ActivityLog(ttk.LabelFrame): + """Activity log panel for displaying application messages.""" + + def __init__( + self, + parent: tk.Misc, + ) -> None: + super().__init__( + parent, + text="Activity Log", + style="Section.TLabelframe", + ) + + self.rowconfigure( + 0, + weight=1, + ) + + self.columnconfigure( + 0, + weight=1, + ) + + self._text = scrolledtext.ScrolledText( + self, + wrap=tk.WORD, + state=tk.DISABLED, + font=( + "Consolas", + 10, + ), + padx=10, + pady=10, + relief=tk.SOLID, + borderwidth=1, + ) + + self._text.grid( + row=0, + column=0, + sticky="nsew", + ) + + def write( + self, + message: str, + ) -> None: + """Append a timestamped message to the activity log.""" + timestamp = datetime.now().strftime( + "%H:%M:%S" + ) + + self._text.configure( + state=tk.NORMAL + ) + + self._text.insert( + tk.END, + f"[{timestamp}] {message}\n", + ) + + self._text.configure( + state=tk.DISABLED + ) + + self._text.see( + tk.END + ) + + def clear(self) -> None: + """Remove all messages from the activity log.""" + self._text.configure( + state=tk.NORMAL + ) + + self._text.delete( + "1.0", + tk.END, + ) + + self._text.configure( + state=tk.DISABLED + ) \ No newline at end of file diff --git a/documents_organizer/ui/components/folder_browser.py b/documents_organizer/ui/components/folder_browser.py new file mode 100644 index 0000000..703a813 --- /dev/null +++ b/documents_organizer/ui/components/folder_browser.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from tkinter import ttk + + +class FolderBrowser(ttk.LabelFrame): + """Displays and manages the folder navigation tree.""" + + def __init__( + self, + parent: tk.Misc, + *, + on_selection_changed: Callable[ + [Path | None], + None, + ] + | None = None, + on_open_selected: Callable[ + [Path], + None, + ] + | None = None, + ) -> None: + super().__init__( + parent, + text="Folder Browser", + style="Section.TLabelframe", + ) + + self._root_path: Path | None = None + + self._item_paths: dict[ + str, + Path, + ] = {} + + self._on_selection_changed = ( + on_selection_changed + ) + + self._on_open_selected = ( + on_open_selected + ) + + self.rowconfigure( + 0, + weight=1, + ) + + self.columnconfigure( + 0, + weight=1, + ) + + self._create_tree() + self._create_empty_state() + + # ------------------------------------------------------------------------- + # Properties + # ------------------------------------------------------------------------- + + @property + def root_path( + self, + ) -> Path | None: + """Return the currently loaded root directory.""" + return self._root_path + + @property + def selected_path( + self, + ) -> Path | None: + """Return the path represented by the current tree selection.""" + selected_items = ( + self._tree.selection() + ) + + if selected_items: + item = selected_items[0] + + else: + item = self._tree.focus() + + if not item: + return None + + return self._item_paths.get( + item + ) + + # ------------------------------------------------------------------------- + # Widget construction + # ------------------------------------------------------------------------- + + def _create_tree(self) -> None: + """Create the Treeview and its scrollbars.""" + self._tree = ttk.Treeview( + self, + show="tree", + selectmode="browse", + ) + + vertical_scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self._tree.yview, + ) + + horizontal_scrollbar = ttk.Scrollbar( + self, + orient=tk.HORIZONTAL, + command=self._tree.xview, + ) + + self._tree.configure( + yscrollcommand=( + vertical_scrollbar.set + ), + xscrollcommand=( + horizontal_scrollbar.set + ), + ) + + self._tree.grid( + row=0, + column=0, + sticky="nsew", + ) + + vertical_scrollbar.grid( + row=0, + column=1, + sticky="ns", + ) + + horizontal_scrollbar.grid( + row=1, + column=0, + sticky="ew", + ) + + self._tree.bind( + "<>", + self._handle_selection_changed, + ) + + self._tree.bind( + "", + self._show_context_menu, + ) + + def _create_empty_state(self) -> None: + """Create the empty-state message.""" + self._empty_label = ttk.Label( + self, + text=( + "No folder selected\n\n" + "Choose Select Folder to begin." + ), + anchor="center", + justify="center", + ) + + self._show_empty_state() + + # ------------------------------------------------------------------------- + # Public operations + # ------------------------------------------------------------------------- + + def load( + self, + directory: Path | str, + ) -> None: + """Load a directory into the folder browser.""" + root = Path( + directory + ).resolve() + + if not root.exists(): + raise FileNotFoundError( + f"Folder does not exist: {root}" + ) + + if not root.is_dir(): + raise NotADirectoryError( + f"Path is not a directory: {root}" + ) + + self._root_path = root + + self._render( + root=root, + preferred_path=root, + ) + + def refresh(self) -> None: + """Refresh the currently loaded folder tree.""" + if self._root_path is None: + return + + if not self._root_path.exists(): + raise FileNotFoundError( + f"Folder does not exist: " + f"{self._root_path}" + ) + + if not self._root_path.is_dir(): + raise NotADirectoryError( + f"Path is not a directory: " + f"{self._root_path}" + ) + + previous_selection = ( + self.selected_path + ) + + self._render( + root=self._root_path, + preferred_path=( + previous_selection + ), + ) + + def clear(self) -> None: + """Clear the folder browser.""" + self._root_path = None + + self._item_paths.clear() + + self._tree.delete( + *self._tree.get_children() + ) + + self._show_empty_state() + + self._notify_selection_changed() + + # ------------------------------------------------------------------------- + # Tree population + # ------------------------------------------------------------------------- + + def _render( + self, + *, + root: Path, + preferred_path: Path | None, + ) -> None: + """Render the folder tree.""" + self._tree.delete( + *self._tree.get_children() + ) + + self._item_paths.clear() + + root_item = self._tree.insert( + "", + "end", + text=str(root), + open=True, + ) + + self._item_paths[ + root_item + ] = root + + preferred_item: str | None = None + + if preferred_path == root: + preferred_item = root_item + + child_preferred_item = ( + self._populate_children( + parent=root_item, + directory=root, + preferred_path=preferred_path, + ) + ) + + if child_preferred_item is not None: + preferred_item = ( + child_preferred_item + ) + + if preferred_item is None: + preferred_item = root_item + + self._tree.selection_set( + preferred_item + ) + + self._tree.focus( + preferred_item + ) + + self._tree.see( + preferred_item + ) + + self._hide_empty_state() + + self._notify_selection_changed() + + def _populate_children( + self, + *, + parent: str, + directory: Path, + preferred_path: Path | None, + ) -> str | None: + """ + Populate all child directories. + + Returns the Treeview item matching preferred_path when found. + """ + try: + items = sorted( + directory.iterdir(), + key=lambda path: ( + path.name.lower() + ), + ) + + except ( + PermissionError, + FileNotFoundError, + OSError, + ): + return None + + preferred_item: str | None = None + + for item_path in items: + try: + if ( + not item_path.is_dir() + or item_path.is_symlink() + ): + continue + + except OSError: + continue + + node = self._tree.insert( + parent, + "end", + text=item_path.name, + ) + + resolved_path = ( + item_path.resolve() + ) + + self._item_paths[ + node + ] = resolved_path + + if ( + preferred_path is not None + and resolved_path + == preferred_path + ): + preferred_item = node + + child_preferred_item = ( + self._populate_children( + parent=node, + directory=resolved_path, + preferred_path=( + preferred_path + ), + ) + ) + + if ( + child_preferred_item + is not None + ): + preferred_item = ( + child_preferred_item + ) + + return preferred_item + + # ------------------------------------------------------------------------- + # Selection + # ------------------------------------------------------------------------- + + def _handle_selection_changed( + self, + event: tk.Event | None = None, + ) -> None: + """Handle Treeview selection changes.""" + self._notify_selection_changed() + + def _notify_selection_changed( + self, + ) -> None: + """Notify the application of a folder selection change.""" + if ( + self._on_selection_changed + is None + ): + return + + self._on_selection_changed( + self.selected_path + ) + + # ------------------------------------------------------------------------- + # Empty state + # ------------------------------------------------------------------------- + + def _show_empty_state(self) -> None: + """Display the empty-state message.""" + self._empty_label.place( + relx=0.5, + rely=0.5, + anchor="center", + ) + + self._empty_label.lift() + + def _hide_empty_state(self) -> None: + """Hide the empty-state message.""" + self._empty_label.place_forget() + + # ------------------------------------------------------------------------- + # Context menu + # ------------------------------------------------------------------------- + + def _show_context_menu( + self, + event: tk.Event, + ) -> None: + """Display the folder context menu.""" + item = self._tree.identify_row( + event.y + ) + + if not item: + return + + self._tree.selection_set( + item + ) + + self._tree.focus( + item + ) + + self._notify_selection_changed() + + context_menu = tk.Menu( + self, + tearoff=0, + ) + + context_menu.add_command( + label="Open in File Manager", + command=self._request_open_selected, + ) + + try: + context_menu.tk_popup( + event.x_root, + event.y_root, + ) + + finally: + context_menu.grab_release() + + def _request_open_selected( + self, + ) -> None: + """Request that the application open the selected folder.""" + if ( + self._on_open_selected + is None + ): + return + + selected_path = ( + self.selected_path + ) + + if selected_path is None: + return + + self._on_open_selected( + selected_path + ) \ No newline at end of file diff --git a/documents_organizer/ui/components/folder_summary.py b/documents_organizer/ui/components/folder_summary.py new file mode 100644 index 0000000..57fbbc5 --- /dev/null +++ b/documents_organizer/ui/components/folder_summary.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import ttk + + +class FolderSummary(ttk.LabelFrame): + """Displays the selected root folder and current operation target.""" + + def __init__( + self, + parent: tk.Misc, + ) -> None: + super().__init__( + parent, + text="Selected Location", + style="Section.TLabelframe", + ) + + self._root_folder_var = tk.StringVar( + value="No folder selected" + ) + + self._target_folder_var = tk.StringVar( + value="No folder selected" + ) + + self.columnconfigure( + 1, + weight=1, + ) + + ttk.Label( + self, + text="Root Folder:", + ).grid( + row=0, + column=0, + sticky="nw", + padx=( + 0, + 10, + ), + pady=2, + ) + + ttk.Label( + self, + textvariable=self._root_folder_var, + style="PathLabel.TLabel", + ).grid( + row=0, + column=1, + sticky="ew", + pady=2, + ) + + ttk.Label( + self, + text="Operation Target:", + ).grid( + row=1, + column=0, + sticky="nw", + padx=( + 0, + 10, + ), + pady=2, + ) + + ttk.Label( + self, + textvariable=self._target_folder_var, + style="PathLabel.TLabel", + ).grid( + row=1, + column=1, + sticky="ew", + pady=2, + ) + + def set_root( + self, + path: Path | str, + ) -> None: + """Set the selected root folder.""" + self._root_folder_var.set( + str(path) + ) + + def set_target( + self, + path: Path | str, + ) -> None: + """Set the current operation target.""" + self._target_folder_var.set( + str(path) + ) + + def clear_root(self) -> None: + """Clear the selected root folder.""" + self._root_folder_var.set( + "No folder selected" + ) + + def clear_target(self) -> None: + """Clear the current operation target.""" + self._target_folder_var.set( + "No folder selected" + ) + + def clear(self) -> None: + """Clear both displayed folder paths.""" + self.clear_root() + self.clear_target() \ No newline at end of file diff --git a/documents_organizer/ui/components/menu_bar.py b/documents_organizer/ui/components/menu_bar.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/components/status_bar.py b/documents_organizer/ui/components/status_bar.py new file mode 100644 index 0000000..61542f2 --- /dev/null +++ b/documents_organizer/ui/components/status_bar.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +from documents_organizer import __version__ + + +class StatusBar(ttk.Frame): + """Displays application status, progress, and version information.""" + + def __init__( + self, + parent: tk.Misc, + ) -> None: + super().__init__( + parent + ) + + self._status_var = tk.StringVar( + value="Ready" + ) + + self.columnconfigure( + 0, + weight=1, + ) + + self._status_label = ttk.Label( + self, + textvariable=self._status_var, + style="Status.TLabel", + ) + + self._status_label.grid( + row=0, + column=0, + sticky="w", + ) + + self._progress_bar = ttk.Progressbar( + self, + mode="indeterminate", + length=180, + ) + + self._progress_bar.grid( + row=0, + column=1, + sticky="e", + padx=( + 10, + 16, + ), + ) + + self._progress_bar.grid_remove() + + self._version_label = ttk.Label( + self, + text=f"v{__version__}", + style="Version.TLabel", + ) + + self._version_label.grid( + row=0, + column=2, + sticky="e", + ) + + def set_status( + self, + message: str, + ) -> None: + """Update the displayed application status.""" + self._status_var.set( + message + ) + + def start_progress(self) -> None: + """Show and start the indeterminate progress indicator.""" + self._progress_bar.grid() + + self._progress_bar.start( + 12 + ) + + def stop_progress(self) -> None: + """Stop and hide the progress indicator.""" + self._progress_bar.stop() + + self._progress_bar.grid_remove() \ No newline at end of file diff --git a/documents_organizer/ui/components/toolbar.py b/documents_organizer/ui/components/toolbar.py new file mode 100644 index 0000000..d00b827 --- /dev/null +++ b/documents_organizer/ui/components/toolbar.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from tkinter import ttk + + +class Toolbar(ttk.Frame): + """Primary application action toolbar.""" + + def __init__( + self, + parent: tk.Misc, + *, + on_select_folder: Callable[[], None], + on_organize: Callable[[], None], + on_flatten: Callable[[], None], + on_cancel: Callable[[], None], + on_open_selected: Callable[[], None], + on_refresh: Callable[[], None], + on_clear_log: Callable[[], None], + ) -> None: + super().__init__( + parent + ) + + self.columnconfigure( + 5, + weight=1, + ) + + self._select_folder_button = ttk.Button( + self, + text="Select Folder", + command=on_select_folder, + style="Primary.TButton", + ) + + self._select_folder_button.grid( + row=0, + column=0, + padx=( + 0, + 6, + ), + ) + + first_separator = ttk.Separator( + self, + orient=tk.VERTICAL, + ) + + first_separator.grid( + row=0, + column=1, + sticky="ns", + padx=8, + ) + + self._organize_button = ttk.Button( + self, + text="Organize", + command=on_organize, + style="Toolbar.TButton", + ) + + self._organize_button.grid( + row=0, + column=2, + padx=6, + ) + + self._flatten_button = ttk.Button( + self, + text="Flatten", + command=on_flatten, + style="Toolbar.TButton", + ) + + self._flatten_button.grid( + row=0, + column=3, + padx=6, + ) + + self._cancel_button = ttk.Button( + self, + text="Cancel", + command=on_cancel, + style="Toolbar.TButton", + ) + + self._cancel_button.grid( + row=0, + column=4, + padx=6, + ) + + second_separator = ttk.Separator( + self, + orient=tk.VERTICAL, + ) + + second_separator.grid( + row=0, + column=6, + sticky="ns", + padx=8, + ) + + self._open_selected_button = ttk.Button( + self, + text="Open Selected", + command=on_open_selected, + style="Toolbar.TButton", + ) + + self._open_selected_button.grid( + row=0, + column=7, + padx=6, + ) + + self._refresh_button = ttk.Button( + self, + text="Refresh", + command=on_refresh, + style="Toolbar.TButton", + ) + + self._refresh_button.grid( + row=0, + column=8, + padx=6, + ) + + self._clear_log_button = ttk.Button( + self, + text="Clear Log", + command=on_clear_log, + style="Toolbar.TButton", + ) + + self._clear_log_button.grid( + row=0, + column=9, + padx=( + 6, + 0, + ), + ) + + def set_states( + self, + *, + select_enabled: bool, + operations_enabled: bool, + cancel_enabled: bool, + utilities_enabled: bool, + ) -> None: + """Update toolbar button states.""" + self._select_folder_button.configure( + state=self._state( + select_enabled + ) + ) + + self._organize_button.configure( + state=self._state( + operations_enabled + ) + ) + + self._flatten_button.configure( + state=self._state( + operations_enabled + ) + ) + + self._cancel_button.configure( + state=self._state( + cancel_enabled + ) + ) + + self._open_selected_button.configure( + state=self._state( + utilities_enabled + ) + ) + + self._refresh_button.configure( + state=self._state( + utilities_enabled + ) + ) + + # Clearing the activity log is always safe. + self._clear_log_button.configure( + state=tk.NORMAL + ) + + @staticmethod + def _state( + enabled: bool, + ) -> str: + """Convert a boolean to a Tkinter widget state.""" + return ( + tk.NORMAL + if enabled + else tk.DISABLED + ) \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index 30def1b..eaf0f65 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -1,11 +1,9 @@ from __future__ import annotations -import os + import queue import threading import tkinter as tk -import tkinter.scrolledtext as scrolledtext -from datetime import datetime from pathlib import Path from tkinter import filedialog, ttk @@ -35,6 +33,11 @@ UI_QUEUE_POLL_INTERVAL_MS, WINDOW_ICON_FILE, ) +from documents_organizer.ui.components.activity_log import ActivityLog +from documents_organizer.ui.components.folder_browser import FolderBrowser +from documents_organizer.ui.components.folder_summary import FolderSummary +from documents_organizer.ui.components.status_bar import StatusBar +from documents_organizer.ui.components.toolbar import Toolbar from documents_organizer.ui.dialogs import ( show_about as show_about_dialog, show_error, @@ -60,17 +63,6 @@ def __init__(self, root: tk.Tk) -> None: ] = queue.Queue() # UI state. - self.root_folder_var = tk.StringVar( - value="No folder selected" - ) - - self.target_folder_var = tk.StringVar( - value="No folder selected" - ) - - self.status_var = tk.StringVar( - value="Ready" - ) self._configure_window() self._configure_styles() @@ -432,11 +424,18 @@ def _create_header(self) -> None: def _create_toolbar(self) -> None: """Create the main action toolbar.""" - toolbar = ttk.Frame( - self.main_frame + self.toolbar = Toolbar( + self.main_frame, + on_select_folder=self.select_folder, + on_organize=self.run_organizer, + on_flatten=self.run_flattener, + on_cancel=self.stop_flattening, + on_open_selected=self.open_selected_folder, + on_refresh=self.refresh_treeview, + on_clear_log=self.clear_log, ) - toolbar.grid( + self.toolbar.grid( row=1, column=0, sticky="ew", @@ -446,142 +445,13 @@ def _create_toolbar(self) -> None: ), ) - # Flexible space between file operations and utility actions. - toolbar.columnconfigure( - 5, - weight=1, - ) - - self.select_folder_button = ttk.Button( - toolbar, - text="Select Folder", - command=self.select_folder, - style="Primary.TButton", - ) - - self.select_folder_button.grid( - row=0, - column=0, - padx=( - 0, - 6, - ), - ) - - first_separator = ttk.Separator( - toolbar, - orient=tk.VERTICAL, - ) - - first_separator.grid( - row=0, - column=1, - sticky="ns", - padx=8, - ) - - self.organize_button = ttk.Button( - toolbar, - text="Organize", - command=self.run_organizer, - style="Toolbar.TButton", - ) - - self.organize_button.grid( - row=0, - column=2, - padx=6, - ) - - self.flatten_button = ttk.Button( - toolbar, - text="Flatten", - command=self.run_flattener, - style="Toolbar.TButton", - ) - - self.flatten_button.grid( - row=0, - column=3, - padx=6, - ) - - self.cancel_button = ttk.Button( - toolbar, - text="Cancel", - command=self.stop_flattening, - style="Toolbar.TButton", - ) - - self.cancel_button.grid( - row=0, - column=4, - padx=6, - ) - - second_separator = ttk.Separator( - toolbar, - orient=tk.VERTICAL, - ) - - second_separator.grid( - row=0, - column=6, - sticky="ns", - padx=8, - ) - - self.open_folder_button = ttk.Button( - toolbar, - text="Open Selected", - command=self.open_selected_folder, - style="Toolbar.TButton", - ) - - self.open_folder_button.grid( - row=0, - column=7, - padx=6, - ) - - self.refresh_button = ttk.Button( - toolbar, - text="Refresh", - command=self.refresh_treeview, - style="Toolbar.TButton", - ) - - self.refresh_button.grid( - row=0, - column=8, - padx=6, - ) - - self.clear_log_button = ttk.Button( - toolbar, - text="Clear Log", - command=self.clear_log, - style="Toolbar.TButton", - ) - - self.clear_log_button.grid( - row=0, - column=9, - padx=( - 6, - 0, - ), - ) - def _create_folder_summary(self) -> None: - """Create the current-folder summary panel.""" - folder_frame = ttk.LabelFrame( - self.main_frame, - text="Selected Location", - style="Section.TLabelframe", + """Create the selected-location summary.""" + self.folder_summary = FolderSummary( + self.main_frame ) - folder_frame.grid( + self.folder_summary.grid( row=2, column=0, sticky="ew", @@ -591,59 +461,21 @@ def _create_folder_summary(self) -> None: ), ) - folder_frame.columnconfigure( - 1, - weight=1, - ) - - ttk.Label( - folder_frame, - text="Root Folder:", - ).grid( - row=0, - column=0, - sticky="nw", - padx=( - 0, - 10, + def _create_folder_browser(self) -> None: + """Create the folder browser component.""" + self.folder_browser = FolderBrowser( + self.paned_window, + on_selection_changed=( + self._on_folder_selection_changed ), - pady=2, - ) - - ttk.Label( - folder_frame, - textvariable=self.root_folder_var, - style="PathLabel.TLabel", - ).grid( - row=0, - column=1, - sticky="ew", - pady=2, - ) - - ttk.Label( - folder_frame, - text="Operation Target:", - ).grid( - row=1, - column=0, - sticky="nw", - padx=( - 0, - 10, + on_open_selected=( + self._open_folder_path ), - pady=2, ) - ttk.Label( - folder_frame, - textvariable=self.target_folder_var, - style="PathLabel.TLabel", - ).grid( - row=1, - column=1, - sticky="ew", - pady=2, + self.paned_window.add( + self.folder_browser, + weight=2, ) def _create_workspace(self) -> None: @@ -659,146 +491,22 @@ def _create_workspace(self) -> None: sticky="nsew", ) - self._create_tree_panel() + self._create_folder_browser() self._create_log_panel() - def _create_tree_panel(self) -> None: - """Create the folder browser panel.""" - tree_frame = ttk.LabelFrame( - self.paned_window, - text="Folder Browser", - style="Section.TLabelframe", - ) - - self.paned_window.add( - tree_frame, - weight=2, - ) - - tree_frame.rowconfigure( - 0, - weight=1, - ) - - tree_frame.columnconfigure( - 0, - weight=1, - ) - - self.tree = ttk.Treeview( - tree_frame, - show="tree", - selectmode="browse", - ) - - tree_scrollbar = ttk.Scrollbar( - tree_frame, - orient=tk.VERTICAL, - command=self.tree.yview, - ) - - horizontal_scrollbar = ttk.Scrollbar( - tree_frame, - orient=tk.HORIZONTAL, - command=self.tree.xview, - ) - - self.tree.configure( - yscrollcommand=tree_scrollbar.set, - xscrollcommand=horizontal_scrollbar.set, - ) - - self.tree.grid( - row=0, - column=0, - sticky="nsew", - ) - - tree_scrollbar.grid( - row=0, - column=1, - sticky="ns", - ) - - horizontal_scrollbar.grid( - row=1, - column=0, - sticky="ew", - ) - - self.tree.bind( - "<>", - self._on_tree_selection, - ) - - self.tree.bind( - "", - self.popup_menu, - ) - - self.tree_empty_label = ttk.Label( - tree_frame, - text=( - "No folder selected\n\n" - "Choose Select Folder to begin." - ), - anchor="center", - justify="center", - ) - - self.tree_empty_label.place( - relx=0.5, - rely=0.5, - anchor="center", - ) - def _create_log_panel(self) -> None: """Create the activity log panel.""" - log_frame = ttk.LabelFrame( - self.paned_window, - text="Activity Log", - style="Section.TLabelframe", + self.activity_log = ActivityLog( + self.paned_window ) self.paned_window.add( - log_frame, + self.activity_log, weight=3, ) - log_frame.rowconfigure( - 0, - weight=1, - ) - - log_frame.columnconfigure( - 0, - weight=1, - ) - - self.log_text = ( - scrolledtext.ScrolledText( - log_frame, - wrap=tk.WORD, - state=tk.DISABLED, - font=( - "Consolas", - 10, - ), - padx=10, - pady=10, - relief=tk.SOLID, - borderwidth=1, - ) - ) - - self.log_text.grid( - row=0, - column=0, - sticky="nsew", - ) - def _create_status_bar(self) -> None: - """Create the bottom status bar.""" + """Create the bottom application status area.""" separator = ttk.Separator( self.main_frame, orient=tk.HORIZONTAL, @@ -814,63 +522,16 @@ def _create_status_bar(self) -> None: ), ) - status_frame = ttk.Frame( + self.status_bar = StatusBar( self.main_frame ) - status_frame.grid( + self.status_bar.grid( row=5, column=0, sticky="ew", ) - status_frame.columnconfigure( - 0, - weight=1, - ) - - status_label = ttk.Label( - status_frame, - textvariable=self.status_var, - style="Status.TLabel", - ) - - status_label.grid( - row=0, - column=0, - sticky="w", - ) - - self.progress_bar = ttk.Progressbar( - status_frame, - mode="indeterminate", - length=180, - ) - - self.progress_bar.grid( - row=0, - column=1, - sticky="e", - padx=( - 10, - 16, - ), - ) - - self.progress_bar.grid_remove() - - version_label = ttk.Label( - status_frame, - text=f"v{__version__}", - style="Version.TLabel", - ) - - version_label.grid( - row=0, - column=2, - sticky="e", - ) - # ------------------------------------------------------------------------- # Folder selection and browser # ------------------------------------------------------------------------- @@ -889,31 +550,42 @@ def select_folder(self) -> None: return selected_folder = filedialog.askdirectory( - title="Select Folder to Organize", + title="Select Folder", ) if not selected_folder: return - self.folder_path = Path( + folder = Path( selected_folder ).resolve() - self.root_folder_var.set( - str(self.folder_path) - ) + try: + self.folder_browser.load( + folder + ) - self.target_folder_var.set( - str(self.folder_path) - ) + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + show_error( + self.root, + "Unable to Open Folder", + str(exc), + ) + return + + self.folder_path = folder - self.update_treeview( - self.folder_path + self.folder_summary.set_root( + folder ) self.log_to_text( - f"Selected folder: " - f"{self.folder_path}" + f"Selected folder: {folder}" ) self.set_status( @@ -922,201 +594,45 @@ def select_folder(self) -> None: self._update_action_states() - def update_treeview( - self, - directory: Path | str, - ) -> None: - """Update the folder tree with a directory structure.""" - directory = Path( - directory - ).resolve() - - self.tree_empty_label.place_forget() - - self.tree.delete( - *self.tree.get_children() - ) - - root_node = self.populate_tree( - directory - ) - - self.tree.selection_set( - root_node - ) - - self.tree.focus( - root_node - ) - - self.tree.see( - root_node - ) - - self.target_folder_var.set( - str(directory) - ) - - def populate_tree( - self, - directory: Path, - ) -> str: - """Populate the root directory in the Treeview.""" - root_node = self.tree.insert( - "", - "end", - text=str(directory), - open=True, - ) - - self.populate_children( - root_node, - directory, - ) - - return root_node - - def populate_children( - self, - parent: str, - directory: Path, - ) -> None: - """Populate subdirectories beneath a Treeview node.""" - try: - items = sorted( - directory.iterdir(), - key=lambda path: ( - path.name.lower() - ), - ) - - except ( - PermissionError, - FileNotFoundError, - OSError, - ): - return - - for item_path in items: - if not item_path.is_dir(): - continue - - node = self.tree.insert( - parent, - "end", - text=item_path.name, - ) - - self.populate_children( - node, - item_path, - ) - def refresh_treeview(self) -> None: """Refresh the displayed folder tree.""" if self.folder_path is None: return - if not self.folder_path.is_dir(): + try: + self.folder_browser.refresh() + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: show_error( self.root, "Folder Unavailable", - ( - "The selected root folder " - "no longer exists." - ), + str(exc), ) return - self.update_treeview( - self.folder_path - ) - self.set_status( "Folder tree refreshed." ) - def _on_tree_selection( - self, - event: tk.Event | None = None, + def _on_folder_selection_changed( + self, + selected_folder: Path | None, ) -> None: - """Update the displayed operation target.""" - selected_folder = ( - self.get_selected_folder() - ) - + """Handle changes to the folder browser selection.""" if selected_folder is None: - self.target_folder_var.set( - "No folder selected" - ) - else: - self.target_folder_var.set( - str(selected_folder) - ) - - self._update_action_states() - - def get_selected_folder( - self, - ) -> Path | None: - """Return the path represented by the selected Treeview item.""" - selected_items = ( - self.tree.selection() - ) - - if not selected_items: - focused_item = ( - self.tree.focus() - ) - - if not focused_item: - return None - - selected_item = ( - focused_item - ) + self.folder_summary.clear_target() else: - selected_item = ( - selected_items[0] - ) - - return self.get_full_path( - selected_item - ) - - def get_full_path( - self, - item: str, - ) -> Path: - """Build the filesystem path represented by a Treeview item.""" - path_components = [ - self.tree.item( - item - )["text"] - ] - - parent = self.tree.parent( - item - ) - - while parent: - path_components.insert( - 0, - self.tree.item( - parent - )["text"], - ) - - parent = self.tree.parent( - parent + self.folder_summary.set_target( + selected_folder ) - return Path( - os.path.join( - *path_components - ) - ).resolve() + self._update_action_states() # ------------------------------------------------------------------------- # Organizer @@ -1125,7 +641,7 @@ def get_full_path( def run_organizer(self) -> None: """Start an organize operation for the selected folder.""" selected_folder = ( - self.get_selected_folder() + self.folder_browser.selected_path ) if selected_folder is None: @@ -1288,7 +804,7 @@ def _handle_organization_error( def run_flattener(self) -> None: """Start a flatten operation for the selected folder.""" selected_folder = ( - self.get_selected_folder() + self.folder_browser.selected_path ) if selected_folder is None: @@ -1527,11 +1043,7 @@ def _begin_operation( "Flattening files..." ) - self.progress_bar.grid() - - self.progress_bar.start( - 12 - ) + self.status_bar.start_progress() self._update_action_states() @@ -1544,9 +1056,7 @@ def _finish_operation( """Finish the active application file operation.""" self.current_operation = None - self.progress_bar.stop() - - self.progress_bar.grid_remove() + self.status_bar.stop_progress() self.set_status( status @@ -1556,100 +1066,117 @@ def _finish_operation( def _update_action_states(self) -> None: """Enable or disable commands based on application state.""" - folder_available = ( - self.folder_path is not None - and self.folder_path.is_dir() - ) - - busy = ( - self.current_operation - is not None + root_available = ( + self.folder_path is not None + and self.folder_path.is_dir() ) - flattening = ( - self.current_operation - == "flatten" - ) - - cancel_available = ( - flattening - and not self.flatten_cancel_event.is_set() + selected_folder = ( + self.folder_browser.selected_path ) - select_state = ( - tk.DISABLED - if busy - else tk.NORMAL + target_available = ( + selected_folder is not None + and selected_folder.is_dir() ) - operation_state = ( - tk.NORMAL - if folder_available - and not busy - else tk.DISABLED - ) - - utility_state = ( - tk.NORMAL - if folder_available - and not busy - else tk.DISABLED + busy = ( + self.current_operation + is not None ) - cancel_state = ( - tk.NORMAL - if cancel_available - else tk.DISABLED + flattening = ( + self.current_operation + == "flatten" ) - self.select_folder_button.configure( - state=select_state + cancel_available = ( + flattening + and not self.flatten_cancel_event.is_set() ) - self.organize_button.configure( - state=operation_state + select_enabled = ( + not busy ) - self.flatten_button.configure( - state=operation_state + operations_enabled = ( + target_available + and not busy ) - self.cancel_button.configure( - state=cancel_state + utilities_enabled = ( + target_available + and root_available + and not busy ) - self.open_folder_button.configure( - state=utility_state + self.toolbar.set_states( + select_enabled=select_enabled, + operations_enabled=operations_enabled, + cancel_enabled=cancel_available, + utilities_enabled=utilities_enabled, ) - self.refresh_button.configure( - state=utility_state + self._update_menu_states( + select_enabled=select_enabled, + operations_enabled=operations_enabled, + cancel_enabled=cancel_available, + utilities_enabled=utilities_enabled, ) - # Menus + def _update_menu_states( + self, + *, + select_enabled: bool, + operations_enabled: bool, + cancel_enabled: bool, + utilities_enabled: bool, + ) -> None: + """Update menu item states.""" self.file_menu.entryconfig( 0, - state=select_state, + state=self._menu_state( + select_enabled + ), ) self.organize_menu.entryconfig( 0, - state=operation_state, + state=self._menu_state( + operations_enabled + ), ) self.organize_menu.entryconfig( 1, - state=operation_state, + state=self._menu_state( + operations_enabled + ), ) self.organize_menu.entryconfig( 3, - state=cancel_state, + state=self._menu_state( + cancel_enabled + ), ) self.view_menu.entryconfig( 1, - state=utility_state, + state=self._menu_state( + utilities_enabled + ), + ) + + @staticmethod + def _menu_state( + enabled: bool, + ) -> str: + """Convert a boolean to a Tkinter menu state.""" + return ( + tk.NORMAL + if enabled + else tk.DISABLED ) # ------------------------------------------------------------------------- @@ -1728,21 +1255,22 @@ def _process_ui_queue(self) -> None: except queue.Empty: pass - self.root.after( - UI_QUEUE_POLL_INTERVAL_MS, - self._process_ui_queue, - ) + if not self.is_closing: + self.root.after( + UI_QUEUE_POLL_INTERVAL_MS, + self._process_ui_queue, + ) # ------------------------------------------------------------------------- # Status and activity log # ------------------------------------------------------------------------- def set_status( - self, - message: str, + self, + message: str, ) -> None: """Update the application status message.""" - self.status_var.set( + self.status_bar.set_status( message ) @@ -1757,52 +1285,19 @@ def _log_startup_message(self) -> None: ) def log_to_text( - self, - message: str, + self, + message: str, ) -> None: - """Append a timestamped message to the activity log.""" - timestamp = ( - datetime.now().strftime( - "%H:%M:%S" - ) - ) - - self.log_text.configure( - state=tk.NORMAL - ) - - self.log_text.insert( - tk.END, - ( - f"[{timestamp}] " - f"{message}\n" - ), - ) - - self.log_text.configure( - state=tk.DISABLED - ) - - self.log_text.see( - tk.END + """Write a message to the activity log.""" + self.activity_log.write( + message ) def clear_log(self) -> None: """Clear the activity log.""" - self.log_text.configure( - state=tk.NORMAL - ) - - self.log_text.delete( - "1.0", - tk.END, - ) - - self.log_text.configure( - state=tk.DISABLED - ) + self.activity_log.clear() - self.log_to_text( + self.activity_log.write( "Activity log cleared." ) @@ -1810,47 +1305,10 @@ def clear_log(self) -> None: # File manager # ------------------------------------------------------------------------- - def popup_menu( - self, - event: tk.Event, - ) -> None: - """Display the folder context menu.""" - selected_item = ( - self.tree.identify_row( - event.y - ) - ) - - if not selected_item: - return - - self.tree.selection_set( - selected_item - ) - - self.tree.focus( - selected_item - ) - - popup = tk.Menu( - self.root, - tearoff=0, - ) - - popup.add_command( - label="Open in File Manager", - command=self.open_selected_folder, - ) - - popup.post( - event.x_root, - event.y_root, - ) - def open_selected_folder(self) -> None: """Open the selected folder in the platform file manager.""" selected_folder = ( - self.get_selected_folder() + self.folder_browser.selected_path ) if selected_folder is None: @@ -1861,15 +1319,24 @@ def open_selected_folder(self) -> None: ) return + self._open_folder_path( + selected_folder + ) + + def _open_folder_path( + self, + folder: Path, + ) -> None: + """Open a folder using the platform file manager.""" try: open_in_file_manager( - selected_folder + folder ) except ( - FileNotFoundError, - NotADirectoryError, - OSError, + FileNotFoundError, + NotADirectoryError, + OSError, ) as exc: show_error( self.root, diff --git a/documents_organizer/ui/operation_controller.py b/documents_organizer/ui/operation_controller.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/tray.py b/documents_organizer/ui/tray.py new file mode 100644 index 0000000..e69de29 From f603d34373628b073303e467fefe67d2149f11b5 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 02:27:16 -0400 Subject: [PATCH 09/20] Extract application shell components - extract application menu bar and menu state handling - extract system tray management - simplify MainWindow tray coordination - preserve thread-safe tray restore and exit behavior - clean up MainWindow shell responsibilities --- documents_organizer/ui/components/menu_bar.py | 238 ++++++++++++++++ documents_organizer/ui/main_window.py | 269 +++--------------- documents_organizer/ui/tray.py | 0 documents_organizer/ui/tray_manager.py | 107 +++++++ 4 files changed, 378 insertions(+), 236 deletions(-) delete mode 100644 documents_organizer/ui/tray.py create mode 100644 documents_organizer/ui/tray_manager.py diff --git a/documents_organizer/ui/components/menu_bar.py b/documents_organizer/ui/components/menu_bar.py index e69de29..fb6607d 100644 --- a/documents_organizer/ui/components/menu_bar.py +++ b/documents_organizer/ui/components/menu_bar.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable + + +class MenuBar: + """Application menu bar and menu state management.""" + + def __init__( + self, + root: tk.Tk, + *, + on_select_folder: Callable[[], None], + on_minimize_to_tray: Callable[[], None], + on_exit: Callable[[], None], + on_organize: Callable[[], None], + on_flatten: Callable[[], None], + on_cancel: Callable[[], None], + on_clear_log: Callable[[], None], + on_refresh: Callable[[], None], + on_about: Callable[[], None], + ) -> None: + self._root = root + + self._menu_bar = tk.Menu( + root + ) + + self._root.config( + menu=self._menu_bar + ) + + self._create_file_menu( + on_select_folder=on_select_folder, + on_minimize_to_tray=on_minimize_to_tray, + on_exit=on_exit, + ) + + self._create_action_menu( + on_organize=on_organize, + on_flatten=on_flatten, + on_cancel=on_cancel, + on_clear_log=on_clear_log, + on_refresh=on_refresh, + ) + + self._create_help_menu( + on_about=on_about, + ) + + # ------------------------------------------------------------------------- + # Menu construction + # ------------------------------------------------------------------------- + + def _create_file_menu( + self, + *, + on_select_folder: Callable[[], None], + on_minimize_to_tray: Callable[[], None], + on_exit: Callable[[], None], + ) -> None: + """Create the File menu.""" + self._file_menu = tk.Menu( + self._menu_bar, + tearoff=0, + ) + + self._file_menu.add_command( + label="Select Folder", + command=on_select_folder, + ) + + self._file_menu.add_separator() + + self._file_menu.add_command( + label="Minimize to Tray", + command=on_minimize_to_tray, + ) + + self._file_menu.add_separator() + + self._file_menu.add_command( + label="Exit", + command=on_exit, + ) + + self._menu_bar.add_cascade( + label="File", + menu=self._file_menu, + ) + + def _create_action_menu( + self, + *, + on_organize: Callable[[], None], + on_flatten: Callable[[], None], + on_cancel: Callable[[], None], + on_clear_log: Callable[[], None], + on_refresh: Callable[[], None], + ) -> None: + """Create the Action menu.""" + action_menu = tk.Menu( + self._menu_bar, + tearoff=0, + ) + + self._organize_menu = tk.Menu( + action_menu, + tearoff=0, + ) + + self._organize_menu.add_command( + label="Organize Files", + command=on_organize, + ) + + self._organize_menu.add_command( + label="Flatten Files", + command=on_flatten, + ) + + self._organize_menu.add_separator() + + self._organize_menu.add_command( + label="Cancel Flatten Operation", + command=on_cancel, + ) + + action_menu.add_cascade( + label="Organize", + menu=self._organize_menu, + ) + + self._view_menu = tk.Menu( + action_menu, + tearoff=0, + ) + + self._view_menu.add_command( + label="Clear Activity Log", + command=on_clear_log, + ) + + self._view_menu.add_command( + label="Refresh Folder Tree", + command=on_refresh, + ) + + action_menu.add_cascade( + label="View", + menu=self._view_menu, + ) + + self._menu_bar.add_cascade( + label="Action", + menu=action_menu, + ) + + def _create_help_menu( + self, + *, + on_about: Callable[[], None], + ) -> None: + """Create the Help menu.""" + help_menu = tk.Menu( + self._menu_bar, + tearoff=0, + ) + + help_menu.add_command( + label="About", + command=on_about, + ) + + self._menu_bar.add_cascade( + label="Help", + menu=help_menu, + ) + + # ------------------------------------------------------------------------- + # State + # ------------------------------------------------------------------------- + + def set_states( + self, + *, + select_enabled: bool, + operations_enabled: bool, + cancel_enabled: bool, + utilities_enabled: bool, + ) -> None: + """Update menu command states.""" + self._file_menu.entryconfig( + 0, + state=self._state( + select_enabled + ), + ) + + self._organize_menu.entryconfig( + 0, + state=self._state( + operations_enabled + ), + ) + + self._organize_menu.entryconfig( + 1, + state=self._state( + operations_enabled + ), + ) + + self._organize_menu.entryconfig( + 3, + state=self._state( + cancel_enabled + ), + ) + + self._view_menu.entryconfig( + 1, + state=self._state( + utilities_enabled + ), + ) + + @staticmethod + def _state( + enabled: bool, + ) -> str: + """Convert a boolean to a Tkinter menu state.""" + return ( + tk.NORMAL + if enabled + else tk.DISABLED + ) \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index eaf0f65..f58340d 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -7,10 +7,6 @@ from pathlib import Path from tkinter import filedialog, ttk -import pystray -from PIL import Image -from pystray import MenuItem as TrayMenuItem - from documents_organizer import __version__ from documents_organizer.platform_utils import open_in_file_manager from documents_organizer.resources import get_image_path @@ -28,15 +24,15 @@ DEFAULT_WINDOW_WIDTH, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, - TRAY_ICON_FILE, - TRAY_ICON_NAME, UI_QUEUE_POLL_INTERVAL_MS, WINDOW_ICON_FILE, ) from documents_organizer.ui.components.activity_log import ActivityLog from documents_organizer.ui.components.folder_browser import FolderBrowser from documents_organizer.ui.components.folder_summary import FolderSummary +from documents_organizer.ui.components.menu_bar import MenuBar from documents_organizer.ui.components.status_bar import StatusBar +from documents_organizer.ui.tray_manager import TrayManager from documents_organizer.ui.components.toolbar import Toolbar from documents_organizer.ui.dialogs import ( show_about as show_about_dialog, @@ -55,13 +51,21 @@ def __init__(self, root: tk.Tk) -> None: self.flatten_cancel_event = threading.Event() self.current_operation: str | None = None self.is_closing = False - self.tray_icon: pystray.Icon | None = None # Worker threads communicate with Tkinter through this queue. self.ui_queue: queue.Queue[ tuple[str, object] ] = queue.Queue() + self.tray_manager = TrayManager( + on_show_requested=( + self._request_show_window + ), + on_exit_requested=( + self._request_exit_application + ), + ) + # UI state. self._configure_window() @@ -212,116 +216,17 @@ def _bind_events(self) -> None: def _create_menu_bar(self) -> None: """Create the application menu bar.""" - menu_bar = tk.Menu( - self.root - ) - - self.root.config( - menu=menu_bar - ) - - # File menu - self.file_menu = tk.Menu( - menu_bar, - tearoff=0, - ) - - self.file_menu.add_command( - label="Select Folder", - command=self.select_folder, - ) - - self.file_menu.add_separator() - - self.file_menu.add_command( - label="Minimize to Tray", - command=self.hide_window, - ) - - self.file_menu.add_separator() - - self.file_menu.add_command( - label="Exit", - command=self.exit_app, - ) - - menu_bar.add_cascade( - label="File", - menu=self.file_menu, - ) - - # Action menu - action_menu = tk.Menu( - menu_bar, - tearoff=0, - ) - - self.organize_menu = tk.Menu( - action_menu, - tearoff=0, - ) - - self.organize_menu.add_command( - label="Organize Files", - command=self.run_organizer, - ) - - self.organize_menu.add_command( - label="Flatten Files", - command=self.run_flattener, - ) - - self.organize_menu.add_separator() - - self.organize_menu.add_command( - label="Cancel Flatten Operation", - command=self.stop_flattening, - ) - - action_menu.add_cascade( - label="Organize", - menu=self.organize_menu, - ) - - self.view_menu = tk.Menu( - action_menu, - tearoff=0, - ) - - self.view_menu.add_command( - label="Clear Activity Log", - command=self.clear_log, - ) - - self.view_menu.add_command( - label="Refresh Folder Tree", - command=self.refresh_treeview, - ) - - action_menu.add_cascade( - label="View", - menu=self.view_menu, - ) - - menu_bar.add_cascade( - label="Action", - menu=action_menu, - ) - - # Help menu - help_menu = tk.Menu( - menu_bar, - tearoff=0, - ) - - help_menu.add_command( - label="About", - command=self.show_about, - ) - - menu_bar.add_cascade( - label="Help", - menu=help_menu, + self.menu_bar = MenuBar( + self.root, + on_select_folder=self.select_folder, + on_minimize_to_tray=self.hide_window, + on_exit=self.exit_app, + on_organize=self.run_organizer, + on_flatten=self.run_flattener, + on_cancel=self.stop_flattening, + on_clear_log=self.clear_log, + on_refresh=self.refresh_treeview, + on_about=self.show_about, ) # ------------------------------------------------------------------------- @@ -1117,68 +1022,13 @@ def _update_action_states(self) -> None: utilities_enabled=utilities_enabled, ) - self._update_menu_states( + self.menu_bar.set_states( select_enabled=select_enabled, operations_enabled=operations_enabled, cancel_enabled=cancel_available, utilities_enabled=utilities_enabled, ) - def _update_menu_states( - self, - *, - select_enabled: bool, - operations_enabled: bool, - cancel_enabled: bool, - utilities_enabled: bool, - ) -> None: - """Update menu item states.""" - self.file_menu.entryconfig( - 0, - state=self._menu_state( - select_enabled - ), - ) - - self.organize_menu.entryconfig( - 0, - state=self._menu_state( - operations_enabled - ), - ) - - self.organize_menu.entryconfig( - 1, - state=self._menu_state( - operations_enabled - ), - ) - - self.organize_menu.entryconfig( - 3, - state=self._menu_state( - cancel_enabled - ), - ) - - self.view_menu.entryconfig( - 1, - state=self._menu_state( - utilities_enabled - ), - ) - - @staticmethod - def _menu_state( - enabled: bool, - ) -> str: - """Convert a boolean to a Tkinter menu state.""" - return ( - tk.NORMAL - if enabled - else tk.DISABLED - ) - # ------------------------------------------------------------------------- # Worker → UI communication # ------------------------------------------------------------------------- @@ -1350,52 +1200,14 @@ def _open_folder_path( def hide_window(self) -> None: """Hide the application in the system tray.""" - self.root.withdraw() - - if ( - self.tray_icon - is not None - ): + if self.tray_manager.is_running: + self.root.withdraw() return try: - with Image.open( - get_image_path( - TRAY_ICON_FILE - ) - ) as source_image: - image = ( - source_image.copy() - ) - - tray_menu = ( - TrayMenuItem( - "Show", - self.show_window, - ), - TrayMenuItem( - "Quit", - self.exit_from_tray, - ), - ) - - self.tray_icon = pystray.Icon( - TRAY_ICON_NAME, - image, - APP_NAME, - tray_menu, - ) - - threading.Thread( - target=self.tray_icon.run, - daemon=True, - ).start() + self.tray_manager.start() except Exception as exc: - self.tray_icon = None - - self.root.deiconify() - show_error( self.root, "System Tray Error", @@ -1405,17 +1217,12 @@ def hide_window(self) -> None: f"{exc}" ), ) + return - def show_window( - self, - icon: pystray.Icon, - menu_item: object, - ) -> None: - """Restore the application from the system tray.""" - icon.stop() - - self.tray_icon = None + self.root.withdraw() + def _request_show_window(self) -> None: + """Queue a request to restore the application window.""" self.ui_queue.put( ( "show_window", @@ -1423,16 +1230,8 @@ def show_window( ) ) - def exit_from_tray( - self, - icon: pystray.Icon, - menu_item: object, - ) -> None: - """Request application exit from the system tray.""" - icon.stop() - - self.tray_icon = None - + def _request_exit_application(self) -> None: + """Queue a request to exit the application.""" self.ui_queue.put( ( "exit_application", @@ -1469,8 +1268,6 @@ def exit_app(self) -> None: self.is_closing = True - if self.tray_icon is not None: - self.tray_icon.stop() - self.tray_icon = None + self.tray_manager.stop() self.root.destroy() \ No newline at end of file diff --git a/documents_organizer/ui/tray.py b/documents_organizer/ui/tray.py deleted file mode 100644 index e69de29..0000000 diff --git a/documents_organizer/ui/tray_manager.py b/documents_organizer/ui/tray_manager.py new file mode 100644 index 0000000..6a5b4e0 --- /dev/null +++ b/documents_organizer/ui/tray_manager.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable + +import pystray +from PIL import Image +from pystray import MenuItem as TrayMenuItem + +from documents_organizer.resources import get_image_path +from documents_organizer.settings import ( + APP_NAME, + TRAY_ICON_FILE, + TRAY_ICON_NAME, +) + + +class TrayManager: + """Manage the application's system tray icon.""" + + def __init__( + self, + *, + on_show_requested: Callable[[], None], + on_exit_requested: Callable[[], None], + ) -> None: + self._on_show_requested = ( + on_show_requested + ) + + self._on_exit_requested = ( + on_exit_requested + ) + + self._icon: pystray.Icon | None = None + + @property + def is_running(self) -> bool: + """Return whether the tray icon is active.""" + return self._icon is not None + + def start(self) -> None: + """Create and start the system tray icon.""" + if self._icon is not None: + return + + with Image.open( + get_image_path( + TRAY_ICON_FILE + ) + ) as source_image: + image = source_image.copy() + + tray_menu = ( + TrayMenuItem( + "Show", + self._handle_show, + ), + TrayMenuItem( + "Quit", + self._handle_exit, + ), + ) + + self._icon = pystray.Icon( + TRAY_ICON_NAME, + image, + APP_NAME, + tray_menu, + ) + + threading.Thread( + target=self._icon.run, + daemon=True, + name="documents-organizer-tray", + ).start() + + def stop(self) -> None: + """Stop and remove the tray icon.""" + icon = self._icon + + if icon is None: + return + + self._icon = None + + icon.stop() + + def _handle_show( + self, + icon: pystray.Icon, + menu_item: object, + ) -> None: + """Handle a tray Show request.""" + self.stop() + + self._on_show_requested() + + def _handle_exit( + self, + icon: pystray.Icon, + menu_item: object, + ) -> None: + """Handle a tray Quit request.""" + self.stop() + + self._on_exit_requested() \ No newline at end of file From 7a91ac6df598b992d2edce688d389362d8534517 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 10:38:12 -0400 Subject: [PATCH 10/20] Extract background operation controller - move organizer and flattener worker threads out of MainWindow - centralize active operation state and lifecycle handling - centralize flatten cancellation with threading.Event - isolate worker-to-UI queue dispatch - add clean controller shutdown handling - prevent concurrent file operations - preserve thread-safe Tkinter callbacks - add comprehensive operation controller tests --- documents_organizer/controllers/__init__.py | 1 + .../controllers/operation_controller.py | 472 +++++++++++ documents_organizer/ui/main_window.py | 381 +++------ .../ui/operation_controller.py | 0 tests/test_operation_controller.py | 759 ++++++++++++++++++ 5 files changed, 1351 insertions(+), 262 deletions(-) create mode 100644 documents_organizer/controllers/__init__.py create mode 100644 documents_organizer/controllers/operation_controller.py delete mode 100644 documents_organizer/ui/operation_controller.py create mode 100644 tests/test_operation_controller.py diff --git a/documents_organizer/controllers/__init__.py b/documents_organizer/controllers/__init__.py new file mode 100644 index 0000000..cfb9ce2 --- /dev/null +++ b/documents_organizer/controllers/__init__.py @@ -0,0 +1 @@ +"""Application controllers for Documents Organizer.""" \ No newline at end of file diff --git a/documents_organizer/controllers/operation_controller.py b/documents_organizer/controllers/operation_controller.py new file mode 100644 index 0000000..8648241 --- /dev/null +++ b/documents_organizer/controllers/operation_controller.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import queue +import threading +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from typing import Literal + +from documents_organizer.services.flattener import ( + FlattenResult, + flatten_directory, +) +from documents_organizer.services.organizer import ( + OrganizationResult, + organize_directory, +) +from documents_organizer.settings import ( + UI_QUEUE_POLL_INTERVAL_MS, +) + + +OperationName = Literal[ + "organize", + "flatten", +] + + +class OperationController: + """Coordinate background file operations.""" + + def __init__( + self, + root: tk.Misc, + *, + on_started: Callable[ + [OperationName, Path], + None, + ], + on_finished: Callable[ + [OperationName], + None, + ], + on_cancel_requested: Callable[ + [], + None, + ], + on_organization_result: Callable[ + [OrganizationResult], + None, + ], + on_organization_error: Callable[ + [str], + None, + ], + on_flatten_result: Callable[ + [FlattenResult], + None, + ], + on_flatten_error: Callable[ + [str], + None, + ], + ) -> None: + self._root = root + + self._on_started = on_started + self._on_finished = on_finished + self._on_cancel_requested = ( + on_cancel_requested + ) + + self._on_organization_result = ( + on_organization_result + ) + + self._on_organization_error = ( + on_organization_error + ) + + self._on_flatten_result = ( + on_flatten_result + ) + + self._on_flatten_error = ( + on_flatten_error + ) + + self._current_operation: ( + OperationName | None + ) = None + + self._flatten_cancel_event = ( + threading.Event() + ) + + self._queue: queue.Queue[ + tuple[str, object] + ] = queue.Queue() + + self._closed = False + self._after_id: str | None = None + + self._schedule_queue_processing() + + # ------------------------------------------------------------------------- + # State + # ------------------------------------------------------------------------- + + @property + def current_operation( + self, + ) -> OperationName | None: + """Return the currently running operation.""" + return self._current_operation + + @property + def is_busy(self) -> bool: + """Return whether a file operation is running.""" + return ( + self._current_operation + is not None + ) + + @property + def is_flattening(self) -> bool: + """Return whether a flatten operation is running.""" + return ( + self._current_operation + == "flatten" + ) + + @property + def can_cancel(self) -> bool: + """Return whether the active flatten operation can be canceled.""" + return ( + self.is_flattening + and not self._flatten_cancel_event.is_set() + ) + + # ------------------------------------------------------------------------- + # Public operations + # ------------------------------------------------------------------------- + + def organize( + self, + folder: Path | str, + ) -> bool: + """Start an organize operation.""" + target = Path( + folder + ).resolve() + + if not self._begin_operation( + "organize", + target, + ): + return False + + try: + worker = threading.Thread( + target=self._run_organizer_worker, + args=( + target, + ), + daemon=True, + name="documents-organizer-organize", + ) + + worker.start() + + except Exception: + self._finish_operation( + "organize" + ) + raise + + return True + + def flatten( + self, + folder: Path | str, + ) -> bool: + """Start a flatten operation.""" + target = Path( + folder + ).resolve() + + if not self._begin_operation( + "flatten", + target, + ): + return False + + try: + worker = threading.Thread( + target=self._run_flattener_worker, + args=( + target, + ), + daemon=True, + name="documents-organizer-flatten", + ) + + worker.start() + + except Exception: + self._finish_operation( + "flatten" + ) + raise + + return True + + def cancel_flatten(self) -> bool: + """Request cancellation of the active flatten operation.""" + if not self.can_cancel: + return False + + self._flatten_cancel_event.set() + + self._on_cancel_requested() + + return True + + def shutdown(self) -> None: + """Stop controller queue processing.""" + if self._closed: + return + + self._closed = True + + if self._after_id is not None: + try: + self._root.after_cancel( + self._after_id + ) + + except tk.TclError: + pass + + self._after_id = None + + # ------------------------------------------------------------------------- + # Operation lifecycle + # ------------------------------------------------------------------------- + + def _begin_operation( + self, + operation: OperationName, + folder: Path, + ) -> bool: + """Mark an operation as active.""" + if self._current_operation is not None: + return False + + if operation == "flatten": + self._flatten_cancel_event.clear() + + self._current_operation = ( + operation + ) + + try: + self._on_started( + operation, + folder, + ) + + except Exception: + self._current_operation = None + raise + + return True + + def _finish_operation( + self, + operation: OperationName, + ) -> None: + """Mark an operation as complete.""" + if ( + self._current_operation + != operation + ): + return + + self._current_operation = None + + self._on_finished( + operation + ) + + # ------------------------------------------------------------------------- + # Worker threads + # ------------------------------------------------------------------------- + + def _run_organizer_worker( + self, + folder: Path, + ) -> None: + """Run the organizer service on a worker thread.""" + try: + result = organize_directory( + folder + ) + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + self._queue.put( + ( + "organization_error", + str(exc), + ) + ) + return + + self._queue.put( + ( + "organization_result", + result, + ) + ) + + def _run_flattener_worker( + self, + folder: Path, + ) -> None: + """Run the flattener service on a worker thread.""" + try: + result = flatten_directory( + folder, + cancel_event=( + self._flatten_cancel_event + ), + ) + + except ( + FileNotFoundError, + NotADirectoryError, + PermissionError, + OSError, + ) as exc: + self._queue.put( + ( + "flatten_error", + str(exc), + ) + ) + return + + self._queue.put( + ( + "flatten_result", + result, + ) + ) + + # ------------------------------------------------------------------------- + # Worker → UI communication + # ------------------------------------------------------------------------- + + def _schedule_queue_processing( + self, + ) -> None: + """Schedule worker queue processing on the Tkinter thread.""" + if self._closed: + return + + self._after_id = self._root.after( + UI_QUEUE_POLL_INTERVAL_MS, + self._process_queue, + ) + + def _process_queue(self) -> None: + """Process worker messages on the Tkinter main thread.""" + self._after_id = None + + try: + while True: + event_name, payload = ( + self._queue.get_nowait() + ) + + self._dispatch_event( + event_name, + payload, + ) + + except queue.Empty: + pass + + self._schedule_queue_processing() + + def _dispatch_event( + self, + event_name: str, + payload: object, + ) -> None: + """Dispatch a worker result to the application.""" + if ( + event_name + == "organization_result" + and isinstance( + payload, + OrganizationResult, + ) + ): + try: + self._on_organization_result( + payload + ) + + finally: + self._finish_operation( + "organize" + ) + + return + + if ( + event_name + == "organization_error" + ): + try: + self._on_organization_error( + str(payload) + ) + + finally: + self._finish_operation( + "organize" + ) + + return + + if ( + event_name + == "flatten_result" + and isinstance( + payload, + FlattenResult, + ) + ): + try: + self._on_flatten_result( + payload + ) + + finally: + self._finish_operation( + "flatten" + ) + + return + + if ( + event_name + == "flatten_error" + ): + try: + self._on_flatten_error( + str(payload) + ) + + finally: + self._finish_operation( + "flatten" + ) \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index f58340d..ce55442 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -2,7 +2,6 @@ import queue -import threading import tkinter as tk from pathlib import Path from tkinter import filedialog, ttk @@ -10,14 +9,6 @@ from documents_organizer import __version__ from documents_organizer.platform_utils import open_in_file_manager from documents_organizer.resources import get_image_path -from documents_organizer.services.flattener import ( - FlattenResult, - flatten_directory, -) -from documents_organizer.services.organizer import ( - OrganizationResult, - organize_directory, -) from documents_organizer.settings import ( APP_NAME, DEFAULT_WINDOW_HEIGHT, @@ -27,6 +18,10 @@ UI_QUEUE_POLL_INTERVAL_MS, WINDOW_ICON_FILE, ) + +from documents_organizer.services.flattener import FlattenResult +from documents_organizer.services.organizer import OrganizationResult + from documents_organizer.ui.components.activity_log import ActivityLog from documents_organizer.ui.components.folder_browser import FolderBrowser from documents_organizer.ui.components.folder_summary import FolderSummary @@ -34,6 +29,10 @@ from documents_organizer.ui.components.status_bar import StatusBar from documents_organizer.ui.tray_manager import TrayManager from documents_organizer.ui.components.toolbar import Toolbar +from documents_organizer.controllers.operation_controller import ( + OperationController, + OperationName, +) from documents_organizer.ui.dialogs import ( show_about as show_about_dialog, show_error, @@ -48,8 +47,6 @@ def __init__(self, root: tk.Tk) -> None: self.root = root self.folder_path: Path | None = None - self.flatten_cancel_event = threading.Event() - self.current_operation: str | None = None self.is_closing = False # Worker threads communicate with Tkinter through this queue. @@ -74,6 +71,27 @@ def __init__(self, root: tk.Tk) -> None: self._create_layout() self._bind_events() + self.operations = OperationController( + self.root, + on_started=self._handle_operation_started, + on_finished=self._handle_operation_finished, + on_cancel_requested=( + self._handle_cancel_requested + ), + on_organization_result=( + self._handle_organization_result + ), + on_organization_error=( + self._handle_organization_error + ), + on_flatten_result=( + self._handle_flatten_result + ), + on_flatten_error=( + self._handle_flatten_error + ), + ) + self._log_startup_message() self._update_action_states() @@ -443,7 +461,7 @@ def _create_status_bar(self) -> None: def select_folder(self) -> None: """Allow the user to select a root directory.""" - if self.current_operation is not None: + if self.operations.is_busy: show_warning( self.root, "Operation in Progress", @@ -568,56 +586,10 @@ def run_organizer(self) -> None: ) return - if not self._begin_operation( - "organize" - ): - return - - self.log_to_text( - f"Organizing: " - f"{selected_folder}" - ) - - worker = threading.Thread( - target=self._run_organizer_worker, - args=( - selected_folder, - ), - daemon=True, - ) - - worker.start() - - def _run_organizer_worker( - self, - selected_folder: Path, - ) -> None: - """Run the organizer service on a worker thread.""" - try: - result = organize_directory( + if not self.operations.organize( selected_folder - ) - - except ( - FileNotFoundError, - NotADirectoryError, - PermissionError, - OSError, - ) as exc: - self.ui_queue.put( - ( - "organization_error", - str(exc), - ) - ) - return - - self.ui_queue.put( - ( - "organization_result", - result, - ) - ) + ): + self._show_operation_in_progress_warning() def _handle_organization_result( self, @@ -675,7 +647,7 @@ def _handle_organization_result( self.refresh_treeview() - self._finish_operation( + self.set_status( ( "Organization complete — " f"{result.moved} files moved." @@ -692,7 +664,7 @@ def _handle_organization_error( f"{message}" ) - self._finish_operation( + self.set_status( "Organization failed." ) @@ -731,61 +703,10 @@ def run_flattener(self) -> None: ) return - if not self._begin_operation( - "flatten" + if not self.operations.flatten( + selected_folder ): - return - - self.flatten_cancel_event.clear() - - self.log_to_text( - f"Flattening: " - f"{selected_folder}" - ) - - worker = threading.Thread( - target=self._run_flattener_worker, - args=( - selected_folder, - ), - daemon=True, - ) - - worker.start() - - def _run_flattener_worker( - self, - selected_folder: Path, - ) -> None: - """Run the flattener service on a worker thread.""" - try: - result = flatten_directory( - selected_folder, - cancel_event=( - self.flatten_cancel_event - ), - ) - - except ( - FileNotFoundError, - NotADirectoryError, - PermissionError, - OSError, - ) as exc: - self.ui_queue.put( - ( - "flatten_error", - str(exc), - ) - ) - return - - self.ui_queue.put( - ( - "flatten_result", - result, - ) - ) + self._show_operation_in_progress_warning() def _handle_flatten_result( self, @@ -861,7 +782,7 @@ def _handle_flatten_result( self.refresh_treeview() - self._finish_operation( + self.set_status( status ) @@ -875,7 +796,7 @@ def _handle_flatten_error( f"{message}" ) - self._finish_operation( + self.set_status( "Flattening failed." ) @@ -887,88 +808,12 @@ def _handle_flatten_error( def stop_flattening(self) -> None: """Request cancellation of an active flatten operation.""" - if ( - self.current_operation - != "flatten" - ): - return - - if ( - self.flatten_cancel_event.is_set() - ): - return - - self.flatten_cancel_event.set() - - self.log_to_text( - "Cancel requested..." - ) - - self.set_status( - "Canceling flatten operation..." - ) - - self._update_action_states() + self.operations.cancel_flatten() # ------------------------------------------------------------------------- # Operation state # ------------------------------------------------------------------------- - def _begin_operation( - self, - operation: str, - ) -> bool: - """Start an application file operation.""" - if ( - self.current_operation - is not None - ): - show_warning( - self.root, - "Operation in Progress", - ( - "Another file operation is " - "already running. Please wait " - "for it to finish." - ), - ) - return False - - self.current_operation = ( - operation - ) - - if operation == "organize": - self.set_status( - "Organizing files..." - ) - - elif operation == "flatten": - self.set_status( - "Flattening files..." - ) - - self.status_bar.start_progress() - - self._update_action_states() - - return True - - def _finish_operation( - self, - status: str, - ) -> None: - """Finish the active application file operation.""" - self.current_operation = None - - self.status_bar.stop_progress() - - self.set_status( - status - ) - - self._update_action_states() - def _update_action_states(self) -> None: """Enable or disable commands based on application state.""" root_available = ( @@ -985,20 +830,7 @@ def _update_action_states(self) -> None: and selected_folder.is_dir() ) - busy = ( - self.current_operation - is not None - ) - - flattening = ( - self.current_operation - == "flatten" - ) - - cancel_available = ( - flattening - and not self.flatten_cancel_event.is_set() - ) + busy = self.operations.is_busy select_enabled = ( not busy @@ -1018,24 +850,93 @@ def _update_action_states(self) -> None: self.toolbar.set_states( select_enabled=select_enabled, operations_enabled=operations_enabled, - cancel_enabled=cancel_available, + cancel_enabled=( + self.operations.can_cancel + ), utilities_enabled=utilities_enabled, ) self.menu_bar.set_states( select_enabled=select_enabled, operations_enabled=operations_enabled, - cancel_enabled=cancel_available, + cancel_enabled=( + self.operations.can_cancel + ), utilities_enabled=utilities_enabled, ) + def _handle_operation_started( + self, + operation: OperationName, + folder: Path, + ) -> None: + """Update the UI when a file operation begins.""" + if operation == "organize": + self.set_status( + "Organizing files..." + ) + + self.log_to_text( + f"Organizing: {folder}" + ) + + elif operation == "flatten": + self.set_status( + "Flattening files..." + ) + + self.log_to_text( + f"Flattening: {folder}" + ) + + self.status_bar.start_progress() + + self._update_action_states() + + def _handle_operation_finished( + self, + operation: OperationName, + ) -> None: + """Update the UI after a file operation finishes.""" + self.status_bar.stop_progress() + + self._update_action_states() + + def _handle_cancel_requested( + self, + ) -> None: + """Update the UI after a flatten cancellation request.""" + self.log_to_text( + "Cancel requested..." + ) + + self.set_status( + "Canceling flatten operation..." + ) + + self._update_action_states() + + def _show_operation_in_progress_warning( + self, + ) -> None: + """Warn that another file operation is already active.""" + show_warning( + self.root, + "Operation in Progress", + ( + "Another file operation is " + "already running. Please wait " + "for it to finish." + ), + ) + # ------------------------------------------------------------------------- # Worker → UI communication # ------------------------------------------------------------------------- def _process_ui_queue(self) -> None: """ - Process messages from worker threads. + Process application-shell messages. Only the Tkinter main thread updates widgets. """ @@ -1045,58 +946,12 @@ def _process_ui_queue(self) -> None: self.ui_queue.get_nowait() ) - if ( - event_name - == "organization_result" - and isinstance( - payload, - OrganizationResult, - ) - ): - self._handle_organization_result( - payload - ) - - elif ( - event_name - == "organization_error" - ): - self._handle_organization_error( - str(payload) - ) - - elif ( - event_name - == "flatten_result" - and isinstance( - payload, - FlattenResult, - ) - ): - self._handle_flatten_result( - payload - ) - - elif ( - event_name - == "flatten_error" - ): - self._handle_flatten_error( - str(payload) - ) - - elif ( - event_name - == "show_window" - ): + if event_name == "show_window": self.root.deiconify() self.root.lift() self.root.focus_force() - elif ( - event_name - == "exit_application" - ): + elif event_name == "exit_application": self.root.deiconify() self.root.lift() @@ -1251,7 +1106,7 @@ def show_about(self) -> None: def exit_app(self) -> None: """Close the application safely.""" - if self.current_operation is not None: + if self.operations.is_busy: show_warning( self.root, "Operation in Progress", @@ -1268,6 +1123,8 @@ def exit_app(self) -> None: self.is_closing = True + self.operations.shutdown() + self.tray_manager.stop() self.root.destroy() \ No newline at end of file diff --git a/documents_organizer/ui/operation_controller.py b/documents_organizer/ui/operation_controller.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_operation_controller.py b/tests/test_operation_controller.py new file mode 100644 index 0000000..79702c3 --- /dev/null +++ b/tests/test_operation_controller.py @@ -0,0 +1,759 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +import pytest + +from documents_organizer.controllers import ( + operation_controller as operation_controller_module, +) +from documents_organizer.controllers.operation_controller import ( + OperationController, +) + + +class FakeRoot: + """Minimal Tkinter root replacement for controller tests.""" + + def __init__(self) -> None: + self._counter = 0 + self.callbacks: dict[ + str, + Callable[[], None], + ] = {} + + self.cancelled_callbacks: list[str] = [] + + def after( + self, + delay_ms: int, + callback: Callable[[], None], + ) -> str: + """Record an after callback without requiring Tkinter.""" + self._counter += 1 + + callback_id = ( + f"after-{self._counter}" + ) + + self.callbacks[ + callback_id + ] = callback + + return callback_id + + def after_cancel( + self, + callback_id: str, + ) -> None: + """Cancel a scheduled callback.""" + self.cancelled_callbacks.append( + callback_id + ) + + self.callbacks.pop( + callback_id, + None, + ) + + def run_next_callback( + self, + ) -> None: + """Execute the next scheduled callback.""" + if not self.callbacks: + raise AssertionError( + "No scheduled callback is available." + ) + + callback_id = next( + iter(self.callbacks) + ) + + callback = self.callbacks.pop( + callback_id + ) + + callback() + + +class ImmediateThread: + """Thread replacement that executes its target synchronously.""" + + def __init__( + self, + *, + target: Callable[..., None], + args: tuple[object, ...] = (), + daemon: bool | None = None, + name: str | None = None, + ) -> None: + self.target = target + self.args = args + self.daemon = daemon + self.name = name + + def start(self) -> None: + """Execute the thread target immediately.""" + self.target( + *self.args + ) + + +class FakeOrganizationResult: + """Test replacement for OrganizationResult.""" + + +class FakeFlattenResult: + """Test replacement for FlattenResult.""" + + +@dataclass +class CallbackRecorder: + """Records OperationController callback activity.""" + + started: list[ + tuple[str, Path] + ] = field( + default_factory=list + ) + + finished: list[str] = field( + default_factory=list + ) + + cancel_requests: int = 0 + + organization_results: list[ + object + ] = field( + default_factory=list + ) + + organization_errors: list[ + str + ] = field( + default_factory=list + ) + + flatten_results: list[ + object + ] = field( + default_factory=list + ) + + flatten_errors: list[ + str + ] = field( + default_factory=list + ) + + def record_started( + self, + operation: str, + folder: Path, + ) -> None: + self.started.append( + ( + operation, + folder, + ) + ) + + def record_finished( + self, + operation: str, + ) -> None: + self.finished.append( + operation + ) + + def record_cancel_request( + self, + ) -> None: + self.cancel_requests += 1 + + +def create_controller( + root: FakeRoot, + callbacks: CallbackRecorder, +) -> OperationController: + """Create an OperationController using test callbacks.""" + return OperationController( + root, + on_started=( + callbacks.record_started + ), + on_finished=( + callbacks.record_finished + ), + on_cancel_requested=( + callbacks.record_cancel_request + ), + on_organization_result=( + callbacks.organization_results.append + ), + on_organization_error=( + callbacks.organization_errors.append + ), + on_flatten_result=( + callbacks.flatten_results.append + ), + on_flatten_error=( + callbacks.flatten_errors.append + ), + ) + + +@pytest.fixture +def controller_environment( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[ + FakeRoot, + CallbackRecorder, + OperationController, +]: + """Create a controller with GUI/thread dependencies replaced.""" + monkeypatch.setattr( + operation_controller_module.threading, + "Thread", + ImmediateThread, + ) + + monkeypatch.setattr( + operation_controller_module, + "OrganizationResult", + FakeOrganizationResult, + ) + + monkeypatch.setattr( + operation_controller_module, + "FlattenResult", + FakeFlattenResult, + ) + + root = FakeRoot() + + callbacks = CallbackRecorder() + + controller = create_controller( + root, + callbacks, + ) + + return ( + root, + callbacks, + controller, + ) + + +def test_controller_starts_idle( + controller_environment, +) -> None: + """Controller should start without an active operation.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + assert controller.current_operation is None + assert controller.is_busy is False + assert controller.is_flattening is False + assert controller.can_cancel is False + + assert callbacks.started == [] + assert callbacks.finished == [] + + # Queue polling should have been scheduled. + assert len(root.callbacks) == 1 + + +def test_organize_success( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Successful organization should dispatch its result.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + result = FakeOrganizationResult() + + received_folders: list[Path] = [] + + def fake_organize( + folder: Path, + ) -> FakeOrganizationResult: + received_folders.append( + folder + ) + + return result + + monkeypatch.setattr( + operation_controller_module, + "organize_directory", + fake_organize, + ) + + started = controller.organize( + tmp_path + ) + + assert started is True + + assert controller.current_operation == "organize" + assert controller.is_busy is True + assert controller.is_flattening is False + assert controller.can_cancel is False + + assert received_folders == [ + tmp_path.resolve() + ] + + assert callbacks.started == [ + ( + "organize", + tmp_path.resolve(), + ) + ] + + # Worker result has been queued but not dispatched yet. + assert callbacks.organization_results == [] + assert callbacks.finished == [] + + root.run_next_callback() + + assert callbacks.organization_results == [ + result + ] + + assert callbacks.finished == [ + "organize" + ] + + assert controller.current_operation is None + assert controller.is_busy is False + + +def test_organize_error( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Organizer errors should be dispatched and finish the operation.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + def fake_organize( + folder: Path, + ) -> FakeOrganizationResult: + raise PermissionError( + "Access denied" + ) + + monkeypatch.setattr( + operation_controller_module, + "organize_directory", + fake_organize, + ) + + assert controller.organize( + tmp_path + ) + + assert controller.is_busy is True + + root.run_next_callback() + + assert callbacks.organization_errors == [ + "Access denied" + ] + + assert callbacks.finished == [ + "organize" + ] + + assert controller.is_busy is False + + +def test_second_operation_is_rejected_while_busy( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A second file operation should not start while one is active.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + result = FakeOrganizationResult() + + monkeypatch.setattr( + operation_controller_module, + "organize_directory", + lambda folder: result, + ) + + assert controller.organize( + tmp_path + ) + + assert controller.is_busy is True + + second_started = controller.flatten( + tmp_path + ) + + assert second_started is False + + assert callbacks.started == [ + ( + "organize", + tmp_path.resolve(), + ) + ] + + root.run_next_callback() + + assert controller.is_busy is False + + +def test_flatten_success( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Successful flattening should dispatch its result.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + result = FakeFlattenResult() + + received_folder: Path | None = None + received_cancel_event = None + + def fake_flatten( + folder: Path, + *, + cancel_event, + ) -> FakeFlattenResult: + nonlocal received_folder + nonlocal received_cancel_event + + received_folder = folder + received_cancel_event = ( + cancel_event + ) + + return result + + monkeypatch.setattr( + operation_controller_module, + "flatten_directory", + fake_flatten, + ) + + assert controller.flatten( + tmp_path + ) + + assert controller.current_operation == "flatten" + assert controller.is_busy is True + assert controller.is_flattening is True + assert controller.can_cancel is True + + assert received_folder == ( + tmp_path.resolve() + ) + + assert received_cancel_event is not None + + root.run_next_callback() + + assert callbacks.flatten_results == [ + result + ] + + assert callbacks.finished == [ + "flatten" + ] + + assert controller.is_busy is False + assert controller.can_cancel is False + + +def test_flatten_error( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Flattener errors should be dispatched and finish the operation.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + def fake_flatten( + folder: Path, + *, + cancel_event, + ) -> FakeFlattenResult: + raise OSError( + "Flatten failed" + ) + + monkeypatch.setattr( + operation_controller_module, + "flatten_directory", + fake_flatten, + ) + + assert controller.flatten( + tmp_path + ) + + root.run_next_callback() + + assert callbacks.flatten_errors == [ + "Flatten failed" + ] + + assert callbacks.finished == [ + "flatten" + ] + + assert controller.is_busy is False + + +def test_flatten_can_be_cancelled( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Cancel should set the event and notify the application once.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + result = FakeFlattenResult() + + received_cancel_event = None + + def fake_flatten( + folder: Path, + *, + cancel_event, + ) -> FakeFlattenResult: + nonlocal received_cancel_event + + received_cancel_event = ( + cancel_event + ) + + return result + + monkeypatch.setattr( + operation_controller_module, + "flatten_directory", + fake_flatten, + ) + + assert controller.flatten( + tmp_path + ) + + assert controller.can_cancel is True + + assert controller.cancel_flatten() is True + + assert callbacks.cancel_requests == 1 + + assert received_cancel_event is not None + assert received_cancel_event.is_set() + + assert controller.can_cancel is False + + # Repeated cancellation requests should be ignored. + assert controller.cancel_flatten() is False + assert callbacks.cancel_requests == 1 + + root.run_next_callback() + + assert controller.is_busy is False + + +def test_cancel_is_rejected_when_not_flattening( + controller_environment, +) -> None: + """Cancellation should only be available for active flattening.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + assert controller.cancel_flatten() is False + + assert callbacks.cancel_requests == 0 + + +def test_rejected_flatten_does_not_clear_cancel_request( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """ + Rejecting another flatten operation must not reset + the current operation's cancellation event. + """ + ( + root, + callbacks, + controller, + ) = controller_environment + + result = FakeFlattenResult() + + received_cancel_event = None + + def fake_flatten( + folder: Path, + *, + cancel_event, + ) -> FakeFlattenResult: + nonlocal received_cancel_event + + received_cancel_event = ( + cancel_event + ) + + return result + + monkeypatch.setattr( + operation_controller_module, + "flatten_directory", + fake_flatten, + ) + + assert controller.flatten( + tmp_path + ) + + assert controller.cancel_flatten() is True + + assert received_cancel_event is not None + assert received_cancel_event.is_set() + + # This must be rejected without touching the active cancellation event. + assert controller.flatten( + tmp_path + ) is False + + assert received_cancel_event.is_set() + + root.run_next_callback() + + +def test_thread_start_failure_restores_idle_state( + controller_environment, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Failure to start a worker thread should roll back operation state.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + class FailingThread: + def __init__( + self, + **kwargs, + ) -> None: + pass + + def start( + self, + ) -> None: + raise RuntimeError( + "Unable to start thread" + ) + + monkeypatch.setattr( + operation_controller_module.threading, + "Thread", + FailingThread, + ) + + with pytest.raises( + RuntimeError, + match="Unable to start thread", + ): + controller.organize( + tmp_path + ) + + assert controller.is_busy is False + assert controller.current_operation is None + + assert callbacks.started == [ + ( + "organize", + tmp_path.resolve(), + ) + ] + + assert callbacks.finished == [ + "organize" + ] + + +def test_shutdown_cancels_queue_processing( + controller_environment, +) -> None: + """Shutdown should cancel scheduled queue polling.""" + ( + root, + callbacks, + controller, + ) = controller_environment + + assert len(root.callbacks) == 1 + + scheduled_id = next( + iter(root.callbacks) + ) + + controller.shutdown() + + assert scheduled_id in ( + root.cancelled_callbacks + ) + + assert root.callbacks == {} + + # Shutdown should also be safe to call more than once. + controller.shutdown() + + assert root.cancelled_callbacks == [ + scheduled_id + ] \ No newline at end of file From 2a97870376883aee3cf61e236118d71517e1bc9d Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 11:16:58 -0400 Subject: [PATCH 11/20] Extract application presentation components - move ttk style configuration into dedicated UI module - extract application header into reusable component - simplify MainWindow presentation setup - preserve existing application appearance and behavior --- .../presenters/operation_presenter.py | 0 documents_organizer/ui/components/header.py | 52 +++ documents_organizer/ui/main_window.py | 300 ++++-------------- documents_organizer/ui/styles.py | 89 ++++++ 4 files changed, 205 insertions(+), 236 deletions(-) create mode 100644 documents_organizer/presenters/operation_presenter.py create mode 100644 documents_organizer/ui/components/header.py create mode 100644 documents_organizer/ui/styles.py diff --git a/documents_organizer/presenters/operation_presenter.py b/documents_organizer/presenters/operation_presenter.py new file mode 100644 index 0000000..e69de29 diff --git a/documents_organizer/ui/components/header.py b/documents_organizer/ui/components/header.py new file mode 100644 index 0000000..0350e95 --- /dev/null +++ b/documents_organizer/ui/components/header.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +from documents_organizer.settings import APP_NAME + + +class Header(ttk.Frame): + """Application title and description header.""" + + def __init__( + self, + parent: tk.Misc, + ) -> None: + super().__init__(parent) + + self.columnconfigure( + 0, + weight=1, + ) + + title = ttk.Label( + self, + text=APP_NAME, + style="AppTitle.TLabel", + ) + + title.grid( + row=0, + column=0, + sticky="w", + ) + + subtitle = ttk.Label( + self, + text=( + "Organize files by modified date " + "and file type." + ), + style="AppSubtitle.TLabel", + ) + + subtitle.grid( + row=1, + column=0, + sticky="w", + pady=( + 2, + 0, + ), + ) \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index ce55442..d4de58d 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -1,14 +1,19 @@ from __future__ import annotations - import queue import tkinter as tk from pathlib import Path from tkinter import filedialog, ttk from documents_organizer import __version__ +from documents_organizer.controllers.operation_controller import ( + OperationController, + OperationName, +) from documents_organizer.platform_utils import open_in_file_manager from documents_organizer.resources import get_image_path +from documents_organizer.services.flattener import FlattenResult +from documents_organizer.services.organizer import OrganizationResult from documents_organizer.settings import ( APP_NAME, DEFAULT_WINDOW_HEIGHT, @@ -18,26 +23,20 @@ UI_QUEUE_POLL_INTERVAL_MS, WINDOW_ICON_FILE, ) - -from documents_organizer.services.flattener import FlattenResult -from documents_organizer.services.organizer import OrganizationResult - from documents_organizer.ui.components.activity_log import ActivityLog from documents_organizer.ui.components.folder_browser import FolderBrowser from documents_organizer.ui.components.folder_summary import FolderSummary +from documents_organizer.ui.components.header import Header from documents_organizer.ui.components.menu_bar import MenuBar from documents_organizer.ui.components.status_bar import StatusBar -from documents_organizer.ui.tray_manager import TrayManager from documents_organizer.ui.components.toolbar import Toolbar -from documents_organizer.controllers.operation_controller import ( - OperationController, - OperationName, -) from documents_organizer.ui.dialogs import ( show_about as show_about_dialog, show_error, show_warning, ) +from documents_organizer.ui.styles import configure_styles +from documents_organizer.ui.tray_manager import TrayManager class MainWindow: @@ -49,24 +48,21 @@ def __init__(self, root: tk.Tk) -> None: self.folder_path: Path | None = None self.is_closing = False - # Worker threads communicate with Tkinter through this queue. self.ui_queue: queue.Queue[ tuple[str, object] ] = queue.Queue() self.tray_manager = TrayManager( - on_show_requested=( - self._request_show_window - ), - on_exit_requested=( - self._request_exit_application - ), + on_show_requested=self._request_show_window, + on_exit_requested=self._request_exit_application, ) - # UI state. - self._configure_window() - self._configure_styles() + + configure_styles( + self.root + ) + self._create_menu_bar() self._create_layout() self._bind_events() @@ -75,21 +71,11 @@ def __init__(self, root: tk.Tk) -> None: self.root, on_started=self._handle_operation_started, on_finished=self._handle_operation_finished, - on_cancel_requested=( - self._handle_cancel_requested - ), - on_organization_result=( - self._handle_organization_result - ), - on_organization_error=( - self._handle_organization_error - ), - on_flatten_result=( - self._handle_flatten_result - ), - on_flatten_error=( - self._handle_flatten_error - ), + on_cancel_requested=self._handle_cancel_requested, + on_organization_result=self._handle_organization_result, + on_organization_error=self._handle_organization_error, + on_flatten_result=self._handle_flatten_result, + on_flatten_error=self._handle_flatten_error, ) self._log_startup_message() @@ -135,92 +121,6 @@ def _configure_window(self) -> None: ): pass - def _configure_styles(self) -> None: - """Configure ttk styles used by the application.""" - style = ttk.Style( - self.root - ) - - style.configure( - "AppTitle.TLabel", - font=( - "Segoe UI", - 18, - "bold", - ), - ) - - style.configure( - "AppSubtitle.TLabel", - font=( - "Segoe UI", - 10, - ), - ) - - style.configure( - "Version.TLabel", - font=( - "Segoe UI", - 9, - ), - ) - - style.configure( - "Toolbar.TButton", - padding=( - 10, - 7, - ), - ) - - style.configure( - "Primary.TButton", - padding=( - 12, - 7, - ), - ) - - style.configure( - "Section.TLabelframe", - padding=10, - ) - - style.configure( - "Section.TLabelframe.Label", - font=( - "Segoe UI", - 10, - "bold", - ), - ) - - style.configure( - "PathLabel.TLabel", - font=( - "Segoe UI", - 9, - ), - ) - - style.configure( - "Status.TLabel", - padding=( - 4, - 2, - ), - ) - - style.configure( - "Treeview", - rowheight=26, - font=( - "Segoe UI", - 10, - ), - ) - def _bind_events(self) -> None: """Bind application-level events.""" self.root.protocol( @@ -295,11 +195,11 @@ def _create_layout(self) -> None: def _create_header(self) -> None: """Create the application header.""" - header = ttk.Frame( + self.header = Header( self.main_frame ) - header.grid( + self.header.grid( row=0, column=0, sticky="ew", @@ -309,42 +209,6 @@ def _create_header(self) -> None: ), ) - header.columnconfigure( - 0, - weight=1, - ) - - title = ttk.Label( - header, - text=APP_NAME, - style="AppTitle.TLabel", - ) - - title.grid( - row=0, - column=0, - sticky="w", - ) - - subtitle = ttk.Label( - header, - text=( - "Organize files by modified date " - "and file type." - ), - style="AppSubtitle.TLabel", - ) - - subtitle.grid( - row=1, - column=0, - sticky="w", - pady=( - 2, - 0, - ), - ) - def _create_toolbar(self) -> None: """Create the main action toolbar.""" self.toolbar = Toolbar( @@ -472,9 +336,7 @@ def select_folder(self) -> None: ) return - selected_folder = filedialog.askdirectory( - title="Select Folder", - ) + selected_folder = filedialog.askdirectory(title="Select Folder") if not selected_folder: return @@ -507,13 +369,9 @@ def select_folder(self) -> None: folder ) - self.log_to_text( - f"Selected folder: {folder}" - ) + self.log_to_text(f"Selected folder: {folder}") - self.set_status( - "Folder selected." - ) + self.set_status("Folder selected.") self._update_action_states() @@ -538,13 +396,11 @@ def refresh_treeview(self) -> None: ) return - self.set_status( - "Folder tree refreshed." - ) + self.set_status("Folder tree refreshed.") def _on_folder_selection_changed( - self, - selected_folder: Path | None, + self, + selected_folder: Path | None, ) -> None: """Handle changes to the folder browser selection.""" if selected_folder is None: @@ -563,9 +419,7 @@ def _on_folder_selection_changed( def run_organizer(self) -> None: """Start an organize operation for the selected folder.""" - selected_folder = ( - self.folder_browser.selected_path - ) + selected_folder = self.folder_browser.selected_path if selected_folder is None: show_error( @@ -664,15 +518,9 @@ def _handle_organization_error( f"{message}" ) - self.set_status( - "Organization failed." - ) + self.set_status("Organization failed.") - show_error( - self.root, - "Organization Failed", - message, - ) + show_error(self.root, "Organization Failed", message) # ------------------------------------------------------------------------- # Flattener @@ -680,9 +528,7 @@ def _handle_organization_error( def run_flattener(self) -> None: """Start a flatten operation for the selected folder.""" - selected_folder = ( - self.folder_browser.selected_path - ) + selected_folder = self.folder_browser.selected_path if selected_folder is None: show_error( @@ -782,9 +628,7 @@ def _handle_flatten_result( self.refresh_treeview() - self.set_status( - status - ) + self.set_status(status) def _handle_flatten_error( self, @@ -796,9 +640,7 @@ def _handle_flatten_error( f"{message}" ) - self.set_status( - "Flattening failed." - ) + self.set_status("Flattening failed.") show_error( self.root, @@ -817,17 +659,15 @@ def stop_flattening(self) -> None: def _update_action_states(self) -> None: """Enable or disable commands based on application state.""" root_available = ( - self.folder_path is not None - and self.folder_path.is_dir() + self.folder_path is not None + and self.folder_path.is_dir() ) - selected_folder = ( - self.folder_browser.selected_path - ) + selected_folder = self.folder_browser.selected_path target_available = ( - selected_folder is not None - and selected_folder.is_dir() + selected_folder is not None + and selected_folder.is_dir() ) busy = self.operations.is_busy @@ -837,14 +677,14 @@ def _update_action_states(self) -> None: ) operations_enabled = ( - target_available - and not busy + target_available + and not busy ) utilities_enabled = ( - target_available - and root_available - and not busy + target_available + and root_available + and not busy ) self.toolbar.set_states( @@ -866,36 +706,28 @@ def _update_action_states(self) -> None: ) def _handle_operation_started( - self, - operation: OperationName, - folder: Path, + self, + operation: OperationName, + folder: Path, ) -> None: """Update the UI when a file operation begins.""" if operation == "organize": - self.set_status( - "Organizing files..." - ) + self.set_status("Organizing files...") - self.log_to_text( - f"Organizing: {folder}" - ) + self.log_to_text(f"Organizing: {folder}") elif operation == "flatten": - self.set_status( - "Flattening files..." - ) + self.set_status("Flattening files...") - self.log_to_text( - f"Flattening: {folder}" - ) + self.log_to_text(f"Flattening: {folder}") self.status_bar.start_progress() self._update_action_states() def _handle_operation_finished( - self, - operation: OperationName, + self, + operation: OperationName, ) -> None: """Update the UI after a file operation finishes.""" self.status_bar.stop_progress() @@ -903,21 +735,19 @@ def _handle_operation_finished( self._update_action_states() def _handle_cancel_requested( - self, + self, ) -> None: """Update the UI after a flatten cancellation request.""" self.log_to_text( "Cancel requested..." ) - self.set_status( - "Canceling flatten operation..." - ) + self.set_status("Canceling flatten operation...") self._update_action_states() def _show_operation_in_progress_warning( - self, + self, ) -> None: """Warn that another file operation is already active.""" show_warning( @@ -971,8 +801,8 @@ def _process_ui_queue(self) -> None: # ------------------------------------------------------------------------- def set_status( - self, - message: str, + self, + message: str, ) -> None: """Update the application status message.""" self.status_bar.set_status( @@ -990,8 +820,8 @@ def _log_startup_message(self) -> None: ) def log_to_text( - self, - message: str, + self, + message: str, ) -> None: """Write a message to the activity log.""" self.activity_log.write( @@ -1012,9 +842,7 @@ def clear_log(self) -> None: def open_selected_folder(self) -> None: """Open the selected folder in the platform file manager.""" - selected_folder = ( - self.folder_browser.selected_path - ) + selected_folder = self.folder_browser.selected_path if selected_folder is None: show_error( @@ -1029,8 +857,8 @@ def open_selected_folder(self) -> None: ) def _open_folder_path( - self, - folder: Path, + self, + folder: Path, ) -> None: """Open a folder using the platform file manager.""" try: diff --git a/documents_organizer/ui/styles.py b/documents_organizer/ui/styles.py new file mode 100644 index 0000000..68de29a --- /dev/null +++ b/documents_organizer/ui/styles.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + + +def configure_styles(root: tk.Misc) -> None: + """Configure ttk styles used by Documents Organizer.""" + style = ttk.Style(root) + + style.configure( + "AppTitle.TLabel", + font=( + "Segoe UI", + 18, + "bold", + ), + ) + + style.configure( + "AppSubtitle.TLabel", + font=( + "Segoe UI", + 10, + ), + ) + + style.configure( + "Version.TLabel", + font=( + "Segoe UI", + 9, + ), + ) + + style.configure( + "Toolbar.TButton", + padding=( + 10, + 7, + ), + ) + + style.configure( + "Primary.TButton", + padding=( + 12, + 7, + ), + ) + + style.configure( + "Section.TLabelframe", + padding=10, + ) + + style.configure( + "Section.TLabelframe.Label", + font=( + "Segoe UI", + 10, + "bold", + ), + ) + + style.configure( + "PathLabel.TLabel", + font=( + "Segoe UI", + 9, + ), + ) + + style.configure( + "Status.TLabel", + padding=( + 4, + 2, + ), + ) + + style.configure( + "Treeview", + rowheight=26, + font=( + "Segoe UI", + 10, + ), + ) \ No newline at end of file From 8979a370a83f65efa98079acf956006ce73b24f4 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 11:34:29 -0400 Subject: [PATCH 12/20] Extract operation result presentation - move organizer result formatting out of MainWindow - move flattener result formatting out of MainWindow - add immutable OperationPresentation model for log and status messages - centralize operation success, failure, and cancellation messaging - add reusable pluralization for operation summaries - keep Tkinter widget updates and dialogs in MainWindow - add tests for organizer and flattener presentations - simplify MainWindow operation result handling --- documents_organizer/presenters/__init__.py | 1 + .../presenters/operation_presenter.py | 172 +++++++++++++++ documents_organizer/ui/main_window.py | 197 ++++++------------ tests/test_operation_presenter.py | 140 +++++++++++++ 4 files changed, 375 insertions(+), 135 deletions(-) create mode 100644 documents_organizer/presenters/__init__.py create mode 100644 tests/test_operation_presenter.py diff --git a/documents_organizer/presenters/__init__.py b/documents_organizer/presenters/__init__.py new file mode 100644 index 0000000..255869f --- /dev/null +++ b/documents_organizer/presenters/__init__.py @@ -0,0 +1 @@ +"""Presentation helpers for Documents Organizer.""" \ No newline at end of file diff --git a/documents_organizer/presenters/operation_presenter.py b/documents_organizer/presenters/operation_presenter.py index e69de29..fc4784c 100644 --- a/documents_organizer/presenters/operation_presenter.py +++ b/documents_organizer/presenters/operation_presenter.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from documents_organizer.services.flattener import FlattenResult +from documents_organizer.services.organizer import OrganizationResult + + +@dataclass(frozen=True, slots=True) +class OperationPresentation: + """User-facing messages produced from an operation result.""" + + log_messages: tuple[str, ...] + status: str + + +def present_organization_result( + result: OrganizationResult, +) -> OperationPresentation: + """Create user-facing messages for an organization result.""" + messages: list[str] = [] + + for extension, count in sorted( + result.by_extension.items() + ): + messages.append( + ( + f"Organized {count} " + f"{extension} {_pluralize('file', count)}." + ) + ) + + if result.skipped: + messages.append( + ( + f"Skipped {result.skipped} " + f"{_pluralize('file', result.skipped)}." + ) + ) + + if result.failed: + messages.append( + ( + f"Encountered {result.failed} " + f"{_pluralize('failure', result.failed)}." + ) + ) + + for failure in result.failures: + messages.append( + f" {failure.path}: {failure.error}" + ) + + messages.append( + ( + "Organization complete. " + f"{result.moved} files moved." + ) + ) + + return OperationPresentation( + log_messages=tuple(messages), + status=( + "Organization complete — " + f"{result.moved} files moved." + ), + ) + + +def present_organization_error( + message: str, +) -> OperationPresentation: + """Create user-facing messages for a fatal organization error.""" + return OperationPresentation( + log_messages=( + f"Organization failed: {message}", + ), + status="Organization failed.", + ) + + +def present_flatten_result( + result: FlattenResult, +) -> OperationPresentation: + """Create user-facing messages for a flatten result.""" + messages: list[str] = [] + + for extension, count in sorted( + result.by_extension.items() + ): + messages.append( + ( + f"Flattened {count} " + f"{extension} {_pluralize('file', count)}." + ) + ) + + if result.skipped: + messages.append( + ( + f"Skipped {result.skipped} " + f"{_pluralize('file', result.skipped)} " + "that did not match their " + "file-type folder." + ) + ) + + if result.failed: + messages.append( + ( + f"Encountered {result.failed} " + f"{_pluralize('failure', result.failed)}." + ) + ) + + for failure in result.failures: + messages.append( + f" {failure.path}: {failure.error}" + ) + + if result.cancelled: + messages.append( + "Flattening canceled." + ) + + status = "Flattening canceled." + + else: + messages.append( + ( + "Flattening complete. " + f"{result.moved} files moved and " + f"{result.directories_removed} " + "empty folders removed." + ) + ) + + status = ( + "Flattening complete — " + f"{result.moved} files moved." + ) + + return OperationPresentation( + log_messages=tuple(messages), + status=status, + ) + + +def present_flatten_error( + message: str, +) -> OperationPresentation: + """Create user-facing messages for a fatal flatten error.""" + return OperationPresentation( + log_messages=( + f"Flattening failed: {message}", + ), + status="Flattening failed.", + ) + + +def _pluralize( + singular: str, + count: int, +) -> str: + """Return a simple singular or plural word.""" + if count == 1: + return singular + + if singular == "failure": + return "failures" + + return f"{singular}s" \ No newline at end of file diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index d4de58d..b109e7b 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -10,6 +10,13 @@ OperationController, OperationName, ) +from documents_organizer.presenters.operation_presenter import ( + OperationPresentation, + present_flatten_error, + present_flatten_result, + present_organization_error, + present_organization_result, +) from documents_organizer.platform_utils import open_in_file_manager from documents_organizer.resources import get_image_path from documents_organizer.services.flattener import FlattenResult @@ -39,6 +46,7 @@ from documents_organizer.ui.tray_manager import TrayManager + class MainWindow: """Main Documents Organizer application window.""" @@ -450,61 +458,9 @@ def _handle_organization_result( result: OrganizationResult, ) -> None: """Display organizer results.""" - for extension, count in sorted( - result.by_extension.items() - ): - label = ( - "file" - if count == 1 - else "files" - ) - - self.log_to_text( - f"Organized {count} " - f"{extension} {label}." - ) - - if result.skipped: - label = ( - "file" - if result.skipped == 1 - else "files" - ) - - self.log_to_text( - f"Skipped " - f"{result.skipped} {label}." - ) - - if result.failed: - label = ( - "failure" - if result.failed == 1 - else "failures" - ) - - self.log_to_text( - f"Encountered " - f"{result.failed} {label}." - ) - - for failure in result.failures: - self.log_to_text( - f" {failure.path}: " - f"{failure.error}" - ) - - self.log_to_text( - f"Organization complete. " - f"{result.moved} files moved." - ) - - self.refresh_treeview() - - self.set_status( - ( - "Organization complete — " - f"{result.moved} files moved." + self._write_completed_operation_presentation( + present_organization_result( + result ) ) @@ -513,14 +469,17 @@ def _handle_organization_error( message: str, ) -> None: """Display a fatal organizer error.""" - self.log_to_text( - f"Organization failed: " - f"{message}" + self._write_operation_presentation( + present_organization_error( + message + ) ) - self.set_status("Organization failed.") - - show_error(self.root, "Organization Failed", message) + show_error( + self.root, + "Organization Failed", + message, + ) # ------------------------------------------------------------------------- # Flattener @@ -559,89 +518,23 @@ def _handle_flatten_result( result: FlattenResult, ) -> None: """Display flattener results.""" - for extension, count in sorted( - result.by_extension.items() - ): - label = ( - "file" - if count == 1 - else "files" - ) - - self.log_to_text( - f"Flattened {count} " - f"{extension} {label}." - ) - - if result.skipped: - label = ( - "file" - if result.skipped == 1 - else "files" - ) - - self.log_to_text( - f"Skipped " - f"{result.skipped} {label} " - "that did not match their " - "file-type folder." - ) - - if result.failed: - label = ( - "failure" - if result.failed == 1 - else "failures" - ) - - self.log_to_text( - f"Encountered " - f"{result.failed} {label}." - ) - - for failure in result.failures: - self.log_to_text( - f" {failure.path}: " - f"{failure.error}" - ) - - if result.cancelled: - self.log_to_text( - "Flattening canceled." + self._write_completed_operation_presentation( + present_flatten_result( + result ) - - status = "Flattening canceled." - - else: - self.log_to_text( - f"Flattening complete. " - f"{result.moved} files moved " - f"and " - f"{result.directories_removed} " - f"empty folders removed." - ) - - status = ( - "Flattening complete — " - f"{result.moved} files moved." - ) - - self.refresh_treeview() - - self.set_status(status) + ) def _handle_flatten_error( self, message: str, ) -> None: """Display a fatal flattener error.""" - self.log_to_text( - f"Flattening failed: " - f"{message}" + self._write_operation_presentation( + present_flatten_error( + message + ) ) - self.set_status("Flattening failed.") - show_error( self.root, "Flattening Failed", @@ -760,6 +653,40 @@ def _show_operation_in_progress_warning( ), ) + # ------------------------------------------------------------------------- + # Operation Presenter state + # ------------------------------------------------------------------------- + + def _write_operation_presentation( + self, + presentation: OperationPresentation, + ) -> None: + """Write operation presentation messages to the UI.""" + for message in presentation.log_messages: + self.activity_log.write( + message + ) + + self.status_bar.set_status( + presentation.status + ) + + def _write_completed_operation_presentation( + self, + presentation: OperationPresentation, + ) -> None: + """Write a completed operation result and refresh the browser.""" + for message in presentation.log_messages: + self.activity_log.write( + message + ) + + self.refresh_treeview() + + self.status_bar.set_status( + presentation.status + ) + # ------------------------------------------------------------------------- # Worker → UI communication # ------------------------------------------------------------------------- diff --git a/tests/test_operation_presenter.py b/tests/test_operation_presenter.py new file mode 100644 index 0000000..f42bada --- /dev/null +++ b/tests/test_operation_presenter.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from documents_organizer.presenters.operation_presenter import ( + present_flatten_error, + present_flatten_result, + present_organization_error, + present_organization_result, +) + + +def test_present_organization_result() -> None: + result = SimpleNamespace( + moved=4, + skipped=1, + failed=1, + by_extension={ + "pdf": 3, + "txt": 1, + }, + failures=[ + SimpleNamespace( + path=Path("broken.pdf"), + error="Access denied", + ) + ], + ) + + presentation = present_organization_result( + result + ) + + assert presentation.log_messages == ( + "Organized 3 pdf files.", + "Organized 1 txt file.", + "Skipped 1 file.", + "Encountered 1 failure.", + " broken.pdf: Access denied", + "Organization complete. 4 files moved.", + ) + + assert presentation.status == ( + "Organization complete — " + "4 files moved." + ) + + +def test_present_organization_error() -> None: + presentation = present_organization_error( + "Access denied" + ) + + assert presentation.log_messages == ( + "Organization failed: Access denied", + ) + + assert presentation.status == ( + "Organization failed." + ) + + +def test_present_flatten_result() -> None: + result = SimpleNamespace( + moved=5, + skipped=2, + failed=0, + directories_removed=3, + cancelled=False, + by_extension={ + "jpg": 2, + "pdf": 3, + }, + failures=[], + ) + + presentation = present_flatten_result( + result + ) + + assert presentation.log_messages == ( + "Flattened 2 jpg files.", + "Flattened 3 pdf files.", + ( + "Skipped 2 files that did not match " + "their file-type folder." + ), + ( + "Flattening complete. " + "5 files moved and " + "3 empty folders removed." + ), + ) + + assert presentation.status == ( + "Flattening complete — " + "5 files moved." + ) + + +def test_present_cancelled_flatten_result() -> None: + result = SimpleNamespace( + moved=2, + skipped=0, + failed=0, + directories_removed=1, + cancelled=True, + by_extension={ + "pdf": 2, + }, + failures=[], + ) + + presentation = present_flatten_result( + result + ) + + assert presentation.log_messages == ( + "Flattened 2 pdf files.", + "Flattening canceled.", + ) + + assert presentation.status == ( + "Flattening canceled." + ) + + +def test_present_flatten_error() -> None: + presentation = present_flatten_error( + "Unable to move file" + ) + + assert presentation.log_messages == ( + "Flattening failed: Unable to move file", + ) + + assert presentation.status == ( + "Flattening failed." + ) \ No newline at end of file From 775ffffc8e1bf8979560849696b78b6f7a1a4c71 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 11:59:46 -0400 Subject: [PATCH 13/20] Add lazy loading to folder browser - load only immediate directories when a root folder is selected - populate child directories on demand when folders are expanded - use placeholder nodes to expose expandable folders without recursive scans - prevent duplicate directory loading - preserve nested folder selections across refreshes - restore selections without loading unrelated directory branches - safely fall back to the root when a selected folder disappears - preserve folder selection and open callbacks - add comprehensive lazy-loading folder browser tests --- .../ui/components/folder_browser.py | 289 ++++-- documents_organizer/ui/main_window.py | 2 +- tests/test_folder_browser.py | 884 ++++++++++++++++++ 3 files changed, 1098 insertions(+), 77 deletions(-) create mode 100644 tests/test_folder_browser.py diff --git a/documents_organizer/ui/components/folder_browser.py b/documents_organizer/ui/components/folder_browser.py index 703a813..7155c85 100644 --- a/documents_organizer/ui/components/folder_browser.py +++ b/documents_organizer/ui/components/folder_browser.py @@ -31,12 +31,23 @@ def __init__( ) self._root_path: Path | None = None + self._root_item: str | None = None + # Map Treeview item IDs to filesystem paths. self._item_paths: dict[ str, Path, ] = {} + # Reverse lookup used when restoring selections. + self._path_items: dict[ + Path, + str, + ] = {} + + # Tree items whose immediate children have already been loaded. + self._loaded_items: set[str] = set() + self._on_selection_changed = ( on_selection_changed ) @@ -80,7 +91,6 @@ def selected_path( if selected_items: item = selected_items[0] - else: item = self._tree.focus() @@ -116,12 +126,8 @@ def _create_tree(self) -> None: ) self._tree.configure( - yscrollcommand=( - vertical_scrollbar.set - ), - xscrollcommand=( - horizontal_scrollbar.set - ), + yscrollcommand=vertical_scrollbar.set, + xscrollcommand=horizontal_scrollbar.set, ) self._tree.grid( @@ -147,6 +153,11 @@ def _create_tree(self) -> None: self._handle_selection_changed, ) + self._tree.bind( + "<>", + self._handle_tree_open, + ) + self._tree.bind( "", self._show_context_menu, @@ -219,16 +230,17 @@ def refresh(self) -> None: self._render( root=self._root_path, - preferred_path=( - previous_selection - ), + preferred_path=previous_selection, ) def clear(self) -> None: """Clear the folder browser.""" self._root_path = None + self._root_item = None self._item_paths.clear() + self._path_items.clear() + self._loaded_items.clear() self._tree.delete( *self._tree.get_children() @@ -239,7 +251,7 @@ def clear(self) -> None: self._notify_selection_changed() # ------------------------------------------------------------------------- - # Tree population + # Tree rendering # ------------------------------------------------------------------------- def _render( @@ -248,12 +260,14 @@ def _render( root: Path, preferred_path: Path | None, ) -> None: - """Render the folder tree.""" + """Render the root and its immediate child directories.""" self._tree.delete( *self._tree.get_children() ) self._item_paths.clear() + self._path_items.clear() + self._loaded_items.clear() root_item = self._tree.insert( "", @@ -262,26 +276,25 @@ def _render( open=True, ) - self._item_paths[ - root_item - ] = root - - preferred_item: str | None = None + self._root_item = root_item - if preferred_path == root: - preferred_item = root_item + self._register_item( + root_item, + root, + ) - child_preferred_item = ( - self._populate_children( - parent=root_item, - directory=root, - preferred_path=preferred_path, - ) + # Only the root's immediate children are loaded here. + self._load_children( + root_item ) - if child_preferred_item is not None: + preferred_item = None + + if preferred_path is not None: preferred_item = ( - child_preferred_item + self._reveal_path( + preferred_path + ) ) if preferred_item is None: @@ -303,20 +316,41 @@ def _render( self._notify_selection_changed() - def _populate_children( + def _load_children( self, - *, - parent: str, - directory: Path, - preferred_path: Path | None, - ) -> str | None: + item: str, + ) -> None: """ - Populate all child directories. + Load one level of child directories beneath a Treeview item. - Returns the Treeview item matching preferred_path when found. + Directories are loaded only when their parent is expanded. """ + if item in self._loaded_items: + return + + directory = self._item_paths.get( + item + ) + + if directory is None: + return + + # Remove the placeholder used to display the expand arrow. + children = self._tree.get_children( + item + ) + + if children: + self._tree.delete( + *children + ) + + self._loaded_items.add( + item + ) + try: - items = sorted( + directory_items = sorted( directory.iterdir(), key=lambda path: ( path.name.lower() @@ -328,61 +362,164 @@ def _populate_children( FileNotFoundError, OSError, ): - return None - - preferred_item: str | None = None + return - for item_path in items: + for item_path in directory_items: try: - if ( - not item_path.is_dir() - or item_path.is_symlink() - ): + if not item_path.is_dir(): + continue + + # Avoid following directory symlinks and junction-like loops. + if item_path.is_symlink(): continue + resolved_path = ( + item_path.resolve() + ) + except OSError: continue - node = self._tree.insert( - parent, - "end", - text=item_path.name, + self._insert_directory( + parent=item, + directory=resolved_path, ) - resolved_path = ( - item_path.resolve() - ) + def _insert_directory( + self, + *, + parent: str, + directory: Path, + ) -> str: + """ + Insert a directory without loading its children. - self._item_paths[ - node - ] = resolved_path - - if ( - preferred_path is not None - and resolved_path - == preferred_path - ): - preferred_item = node - - child_preferred_item = ( - self._populate_children( - parent=node, - directory=resolved_path, - preferred_path=( - preferred_path - ), + A placeholder child gives the directory an expansion arrow. + """ + node = self._tree.insert( + parent, + "end", + text=directory.name, + ) + + self._register_item( + node, + directory, + ) + + # The placeholder makes Tk display an expansion arrow. + # It is removed the first time the directory is expanded. + self._tree.insert( + node, + "end", + text="", + ) + + return node + + def _register_item( + self, + item: str, + path: Path, + ) -> None: + """Associate a Treeview item with its filesystem path.""" + self._item_paths[ + item + ] = path + + self._path_items[ + path + ] = item + + # ------------------------------------------------------------------------- + # Lazy loading + # ------------------------------------------------------------------------- + + def _handle_tree_open( + self, + event: tk.Event | None = None, + ) -> None: + """Load a directory's children when it is expanded.""" + item = self._tree.focus() + + if not item: + return + + self._load_children( + item + ) + + def _reveal_path( + self, + path: Path, + ) -> str | None: + """ + Load only the ancestors required to reveal a path. + + Used to restore the selected directory after a refresh. + """ + if self._root_path is None: + return None + + target = Path( + path + ).resolve() + + root = self._root_path + + try: + relative_path = ( + target.relative_to( + root ) ) - if ( - child_preferred_item - is not None - ): - preferred_item = ( - child_preferred_item + except ValueError: + return None + + if ( + relative_path + == Path(".") + ): + return self._root_item + + if self._root_item is None: + return None + + current_item = ( + self._root_item + ) + + current_path = root + + for part in relative_path.parts: + # Make sure the current directory's immediate children exist. + self._load_children( + current_item + ) + + self._tree.item( + current_item, + open=True, + ) + + current_path = ( + current_path + / part + ).resolve() + + next_item = ( + self._path_items.get( + current_path ) + ) + + if next_item is None: + return None + + current_item = next_item - return preferred_item + return current_item # ------------------------------------------------------------------------- # Selection diff --git a/documents_organizer/ui/main_window.py b/documents_organizer/ui/main_window.py index b109e7b..97163e6 100644 --- a/documents_organizer/ui/main_window.py +++ b/documents_organizer/ui/main_window.py @@ -654,7 +654,7 @@ def _show_operation_in_progress_warning( ) # ------------------------------------------------------------------------- - # Operation Presenter state + # Operation presentation # ------------------------------------------------------------------------- def _write_operation_presentation( diff --git a/tests/test_folder_browser.py b/tests/test_folder_browser.py new file mode 100644 index 0000000..a108e67 --- /dev/null +++ b/tests/test_folder_browser.py @@ -0,0 +1,884 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from documents_organizer.ui.components.folder_browser import FolderBrowser + + +class FakeTree: + """Minimal Treeview replacement for FolderBrowser tests.""" + + def __init__(self) -> None: + self._counter = 0 + + self._nodes: dict[ + str, + dict[str, object], + ] = {} + + self._children: dict[ + str, + list[str], + ] = { + "": [], + } + + self._selection: tuple[str, ...] = () + self._focus = "" + + def insert( + self, + parent: str, + index: str, + *, + text: str = "", + open: bool = False, + ) -> str: + """Insert a fake tree item.""" + self._counter += 1 + + item = f"I{self._counter:03d}" + + self._nodes[item] = { + "parent": parent, + "text": text, + "open": open, + } + + self._children.setdefault( + parent, + [], + ).append( + item + ) + + self._children[ + item + ] = [] + + return item + + def get_children( + self, + item: str = "", + ) -> tuple[str, ...]: + """Return an item's children.""" + return tuple( + self._children.get( + item, + [], + ) + ) + + def delete( + self, + *items: str, + ) -> None: + """Delete tree items and their descendants.""" + for item in items: + self._delete_item( + item + ) + + def _delete_item( + self, + item: str, + ) -> None: + """Delete one tree item recursively.""" + for child in list( + self._children.get( + item, + [], + ) + ): + self._delete_item( + child + ) + + node = self._nodes.pop( + item, + None, + ) + + self._children.pop( + item, + None, + ) + + if node is not None: + parent = str( + node["parent"] + ) + + parent_children = ( + self._children.get( + parent, + [], + ) + ) + + if item in parent_children: + parent_children.remove( + item + ) + + if item in self._selection: + self._selection = tuple( + selected + for selected + in self._selection + if selected != item + ) + + if self._focus == item: + self._focus = "" + + def selection( + self, + ) -> tuple[str, ...]: + """Return the current selection.""" + return self._selection + + def selection_set( + self, + item: str, + ) -> None: + """Set the current selection.""" + self._selection = ( + item, + ) + + def focus( + self, + item: str | None = None, + ) -> str: + """Get or set the focused tree item.""" + if item is not None: + self._focus = item + + return self._focus + + def see( + self, + item: str, + ) -> None: + """Pretend to scroll an item into view.""" + + def item( + self, + item: str, + **options: object, + ) -> dict[str, object]: + """Read or update tree item options.""" + node = self._nodes[ + item + ] + + node.update( + options + ) + + return dict( + node + ) + + +class FakeLabel: + """Minimal label replacement for empty-state tests.""" + + def __init__(self) -> None: + self.visible = False + + def place( + self, + **kwargs: object, + ) -> None: + """Show the label.""" + self.visible = True + + def place_forget( + self, + ) -> None: + """Hide the label.""" + self.visible = False + + def lift( + self, + ) -> None: + """Pretend to raise the label.""" + + +def create_browser( + *, + on_selection_changed: Callable[ + [Path | None], + None, + ] + | None = None, + on_open_selected: Callable[ + [Path], + None, + ] + | None = None, +) -> FolderBrowser: + """ + Create a FolderBrowser without initializing real Tk widgets. + + The filesystem and lazy-loading logic can then be tested + independently from Tkinter. + """ + browser = FolderBrowser.__new__( + FolderBrowser + ) + + browser._root_path = None + browser._root_item = None + + browser._item_paths = {} + browser._path_items = {} + browser._loaded_items = set() + + browser._on_selection_changed = ( + on_selection_changed + ) + + browser._on_open_selected = ( + on_open_selected + ) + + browser._tree = FakeTree() + browser._empty_label = FakeLabel() + + return browser + + +def test_load_only_populates_immediate_directories( + tmp_path: Path, +) -> None: + """Initial loading should not recursively scan child directories.""" + alpha = ( + tmp_path + / "Alpha" + ) + + nested = ( + alpha + / "Nested" + ) + + deep = ( + nested + / "Deep" + ) + + beta = ( + tmp_path + / "Beta" + ) + + deep.mkdir( + parents=True + ) + + beta.mkdir() + + browser = create_browser() + + browser.load( + tmp_path + ) + + loaded_paths = set( + browser._path_items + ) + + assert tmp_path.resolve() in loaded_paths + assert alpha.resolve() in loaded_paths + assert beta.resolve() in loaded_paths + + # Nested directories must not be discovered yet. + assert nested.resolve() not in loaded_paths + assert deep.resolve() not in loaded_paths + + assert browser.selected_path == ( + tmp_path.resolve() + ) + + +def test_child_directory_is_not_marked_loaded_initially( + tmp_path: Path, +) -> None: + """Child directories should remain unloaded until expanded.""" + child = ( + tmp_path + / "Child" + ) + + ( + child + / "Grandchild" + ).mkdir( + parents=True + ) + + browser = create_browser() + + browser.load( + tmp_path + ) + + root_item = ( + browser._path_items[ + tmp_path.resolve() + ] + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + assert root_item in ( + browser._loaded_items + ) + + assert child_item not in ( + browser._loaded_items + ) + + +def test_expanding_directory_loads_one_level( + tmp_path: Path, +) -> None: + """Expanding a folder should load only its immediate children.""" + child = ( + tmp_path + / "Child" + ) + + grandchild = ( + child + / "Grandchild" + ) + + great_grandchild = ( + grandchild + / "GreatGrandchild" + ) + + great_grandchild.mkdir( + parents=True + ) + + browser = create_browser() + + browser.load( + tmp_path + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + browser._tree.focus( + child_item + ) + + browser._handle_tree_open() + + assert grandchild.resolve() in ( + browser._path_items + ) + + # Grandchild itself has not been expanded, + # so its children must remain undiscovered. + assert great_grandchild.resolve() not in ( + browser._path_items + ) + + assert child_item in ( + browser._loaded_items + ) + + +def test_empty_directory_placeholder_is_removed_when_expanded( + tmp_path: Path, +) -> None: + """Expanding an empty folder should remove its placeholder.""" + empty_folder = ( + tmp_path + / "Empty" + ) + + empty_folder.mkdir() + + browser = create_browser() + + browser.load( + tmp_path + ) + + empty_item = ( + browser._path_items[ + empty_folder.resolve() + ] + ) + + # Newly inserted directories receive one placeholder. + assert len( + browser._tree.get_children( + empty_item + ) + ) == 1 + + browser._tree.focus( + empty_item + ) + + browser._handle_tree_open() + + assert ( + browser._tree.get_children( + empty_item + ) + == () + ) + + assert empty_item in ( + browser._loaded_items + ) + + +def test_loading_children_twice_does_not_duplicate_items( + tmp_path: Path, +) -> None: + """A directory should only be loaded once.""" + child = ( + tmp_path + / "Child" + ) + + grandchild = ( + child + / "Grandchild" + ) + + grandchild.mkdir( + parents=True + ) + + browser = create_browser() + + browser.load( + tmp_path + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + browser._load_children( + child_item + ) + + children_after_first_load = ( + browser._tree.get_children( + child_item + ) + ) + + browser._load_children( + child_item + ) + + children_after_second_load = ( + browser._tree.get_children( + child_item + ) + ) + + assert children_after_first_load == ( + children_after_second_load + ) + + assert ( + len( + children_after_second_load + ) + == 1 + ) + + +def test_directories_are_sorted_case_insensitively( + tmp_path: Path, +) -> None: + """Folder entries should be shown in case-insensitive name order.""" + for name in ( + "Zulu", + "alpha", + "Bravo", + ): + ( + tmp_path + / name + ).mkdir() + + browser = create_browser() + + browser.load( + tmp_path + ) + + root_item = ( + browser._root_item + ) + + assert root_item is not None + + child_items = ( + browser._tree.get_children( + root_item + ) + ) + + names = [ + str( + browser._tree.item( + item + )["text"] + ) + for item in child_items + ] + + assert names == [ + "alpha", + "Bravo", + "Zulu", + ] + + +def test_refresh_restores_nested_selection_lazily( + tmp_path: Path, +) -> None: + """ + Refresh should restore a nested selection without + recursively loading unrelated branches. + """ + selected_folder = ( + tmp_path + / "Alpha" + / "One" + / "Selected" + ) + + selected_folder.mkdir( + parents=True + ) + + unrelated_folder = ( + tmp_path + / "Beta" + / "Unrelated" + ) + + unrelated_folder.mkdir( + parents=True + ) + + browser = create_browser() + + browser.load( + tmp_path + ) + + alpha_item = ( + browser._path_items[ + ( + tmp_path + / "Alpha" + ).resolve() + ] + ) + + browser._load_children( + alpha_item + ) + + one_item = ( + browser._path_items[ + ( + tmp_path + / "Alpha" + / "One" + ).resolve() + ] + ) + + browser._load_children( + one_item + ) + + selected_item = ( + browser._path_items[ + selected_folder.resolve() + ] + ) + + browser._tree.selection_set( + selected_item + ) + + browser._tree.focus( + selected_item + ) + + assert browser.selected_path == ( + selected_folder.resolve() + ) + + browser.refresh() + + assert browser.selected_path == ( + selected_folder.resolve() + ) + + # Restoring Alpha/One/Selected must not recursively + # load the unrelated Beta branch. + assert unrelated_folder.resolve() not in ( + browser._path_items + ) + + +def test_refresh_falls_back_to_root_when_selection_was_deleted( + tmp_path: Path, +) -> None: + """Refresh should safely select the root if the old target disappeared.""" + child = ( + tmp_path + / "Child" + ) + + child.mkdir() + + browser = create_browser() + + browser.load( + tmp_path + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + browser._tree.selection_set( + child_item + ) + + browser._tree.focus( + child_item + ) + + assert browser.selected_path == ( + child.resolve() + ) + + child.rmdir() + + browser.refresh() + + assert browser.selected_path == ( + tmp_path.resolve() + ) + + +def test_selection_callback_receives_selected_path( + tmp_path: Path, +) -> None: + """Folder selections should notify the application with a Path.""" + child = ( + tmp_path + / "Child" + ) + + child.mkdir() + + selections: list[ + Path | None + ] = [] + + browser = create_browser( + on_selection_changed=( + selections.append + ) + ) + + browser.load( + tmp_path + ) + + # Loading selects the root. + assert selections[-1] == ( + tmp_path.resolve() + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + browser._tree.selection_set( + child_item + ) + + browser._tree.focus( + child_item + ) + + browser._handle_selection_changed() + + assert selections[-1] == ( + child.resolve() + ) + + +def test_open_callback_receives_selected_path( + tmp_path: Path, +) -> None: + """Open requests should pass the selected directory to the application.""" + child = ( + tmp_path + / "Child" + ) + + child.mkdir() + + opened_paths: list[Path] = [] + + browser = create_browser( + on_open_selected=( + opened_paths.append + ) + ) + + browser.load( + tmp_path + ) + + child_item = ( + browser._path_items[ + child.resolve() + ] + ) + + browser._tree.selection_set( + child_item + ) + + browser._tree.focus( + child_item + ) + + browser._request_open_selected() + + assert opened_paths == [ + child.resolve() + ] + + +def test_clear_resets_browser( + tmp_path: Path, +) -> None: + """Clearing should remove all tree and path state.""" + child = ( + tmp_path + / "Child" + ) + + child.mkdir() + + selections: list[ + Path | None + ] = [] + + browser = create_browser( + on_selection_changed=( + selections.append + ) + ) + + browser.load( + tmp_path + ) + + browser.clear() + + assert browser.root_path is None + assert browser.selected_path is None + assert browser._root_item is None + + assert browser._item_paths == {} + assert browser._path_items == {} + assert browser._loaded_items == set() + + assert ( + browser._tree.get_children() + == () + ) + + assert browser._empty_label.visible is True + + assert selections[-1] is None + + +def test_load_rejects_missing_directory( + tmp_path: Path, +) -> None: + """Loading a missing directory should fail clearly.""" + browser = create_browser() + + missing = ( + tmp_path + / "Missing" + ) + + with pytest.raises( + FileNotFoundError + ): + browser.load( + missing + ) + + +def test_load_rejects_file_path( + tmp_path: Path, +) -> None: + """Loading a file instead of a directory should fail clearly.""" + file_path = ( + tmp_path + / "document.txt" + ) + + file_path.write_text( + "test", + encoding="utf-8", + ) + + browser = create_browser() + + with pytest.raises( + NotADirectoryError + ): + browser.load( + file_path + ) \ No newline at end of file From dcc561bf60e6dfed4c925ac7832398b6329f5216 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Wed, 26 Aug 2026 15:31:54 -0400 Subject: [PATCH 14/20] Add workflow coverage - add organizer-to-flattener workflow integration tests - verify duplicate filenames survive round-trip operations - verify extensionless files survive organize and flatten workflows - verify repeated organize and flatten operations remain safe --- tests/test_workflow.py | 265 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 tests/test_workflow.py diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..939d6ff --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from pathlib import Path + +from documents_organizer.services.flattener import flatten_directory +from documents_organizer.services.organizer import organize_directory + + +def test_organize_then_flatten_round_trip( + tmp_path: Path, +) -> None: + """Files should survive an organize/flatten round trip.""" + pdf_file = tmp_path / "report.pdf" + image_file = tmp_path / "photo.jpg" + + nested_folder = tmp_path / "project" + nested_folder.mkdir() + + text_file = nested_folder / "notes.txt" + + pdf_file.write_text( + "PDF contents", + encoding="utf-8", + ) + + image_file.write_bytes( + b"fake-image-data" + ) + + text_file.write_text( + "Project notes", + encoding="utf-8", + ) + + organize_result = organize_directory( + tmp_path + ) + + assert organize_result.moved == 3 + assert organize_result.failed == 0 + + # Original files should have moved. + assert not pdf_file.exists() + assert not image_file.exists() + assert not text_file.exists() + + # Empty original source directories are intentionally preserved. + assert nested_folder.exists() + assert nested_folder.is_dir() + + organized_files = [ + path + for path in tmp_path.rglob("*") + if path.is_file() + ] + + assert len(organized_files) == 3 + + flatten_result = flatten_directory( + tmp_path + ) + + assert flatten_result.moved == 3 + assert flatten_result.failed == 0 + assert flatten_result.cancelled is False + + assert ( + tmp_path + / "report.pdf" + ).read_text( + encoding="utf-8" + ) == "PDF contents" + + assert ( + tmp_path + / "photo.jpg" + ).read_bytes() == b"fake-image-data" + + assert ( + tmp_path + / "notes.txt" + ).read_text( + encoding="utf-8" + ) == "Project notes" + + # The original nested directory remains because + # it was not created by the organizer. + assert nested_folder.exists() + + +def test_round_trip_preserves_duplicate_file_contents( + tmp_path: Path, +) -> None: + """Duplicate filenames should survive without overwriting one another.""" + first_folder = tmp_path / "first" + second_folder = tmp_path / "second" + + first_folder.mkdir() + second_folder.mkdir() + + first_file = first_folder / "report.pdf" + second_file = second_folder / "report.pdf" + + first_file.write_text( + "first report", + encoding="utf-8", + ) + + second_file.write_text( + "second report", + encoding="utf-8", + ) + + organize_result = organize_directory( + tmp_path + ) + + assert organize_result.moved == 2 + assert organize_result.failed == 0 + + flatten_result = flatten_directory( + tmp_path + ) + + assert flatten_result.moved == 2 + assert flatten_result.failed == 0 + + root_pdfs = sorted( + tmp_path.glob("*.pdf") + ) + + assert len(root_pdfs) == 2 + + assert { + path.read_text( + encoding="utf-8" + ) + for path in root_pdfs + } == { + "first report", + "second report", + } + + +def test_round_trip_preserves_extensionless_file( + tmp_path: Path, +) -> None: + """Extensionless files should survive the full workflow.""" + source = tmp_path / "LICENSE" + + source.write_text( + "MIT License", + encoding="utf-8", + ) + + organize_result = organize_directory( + tmp_path + ) + + assert organize_result.moved == 1 + assert organize_result.failed == 0 + + assert not source.exists() + + organized_files = [ + path + for path in tmp_path.rglob("*") + if path.is_file() + ] + + assert len(organized_files) == 1 + assert organized_files[0].name == "LICENSE" + + flatten_result = flatten_directory( + tmp_path + ) + + assert flatten_result.moved == 1 + assert flatten_result.failed == 0 + + restored = tmp_path / "LICENSE" + + assert restored.exists() + + assert restored.read_text( + encoding="utf-8" + ) == "MIT License" + + +def test_second_organize_does_not_move_already_organized_files( + tmp_path: Path, +) -> None: + """Running the organizer twice should not reorganize its own output.""" + source = tmp_path / "document.pdf" + + source.write_text( + "document", + encoding="utf-8", + ) + + first_result = organize_directory( + tmp_path + ) + + assert first_result.moved == 1 + + organized_files_before = { + path.relative_to(tmp_path) + for path in tmp_path.rglob("*") + if path.is_file() + } + + second_result = organize_directory( + tmp_path + ) + + organized_files_after = { + path.relative_to(tmp_path) + for path in tmp_path.rglob("*") + if path.is_file() + } + + assert second_result.moved == 0 + assert second_result.failed == 0 + + assert ( + organized_files_after + == organized_files_before + ) + + +def test_second_flatten_is_safe( + tmp_path: Path, +) -> None: + """Flattening an already flattened directory should be harmless.""" + source = tmp_path / "document.pdf" + + source.write_text( + "document", + encoding="utf-8", + ) + + organize_directory( + tmp_path + ) + + first_result = flatten_directory( + tmp_path + ) + + assert first_result.moved == 1 + + second_result = flatten_directory( + tmp_path + ) + + assert second_result.moved == 0 + assert second_result.failed == 0 + assert second_result.cancelled is False + + assert source.exists() + + assert source.read_text( + encoding="utf-8" + ) == "document" \ No newline at end of file From 37f674192e114a321012a585817f104610a734db Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Thu, 27 Aug 2026 07:19:31 -0400 Subject: [PATCH 15/20] Update README for v0.2.0 - document the new date-and-type organization structure - explain nested file centralization and duplicate protection - document flatten behavior and its limitations - explain lazy-loaded folder browsing and operation targets - document file safety and repeated-operation behavior - update the project architecture and testing sections - clarify Windows-focused platform support - update packaging status and the v0.2.0 roadmap --- README.md | 901 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 823 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 74269cf..af248f3 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,93 @@ ![Documents Organizer Logo](images/logo/folder-48.png) -A Python desktop utility for organizing directories by file type and modification date, flattening previously organized folder structures, and managing collections of files through a graphical interface. +A Python desktop utility for safely organizing files by modification date and file type, flattening organizer-generated folder structures, and managing file collections through a graphical interface. Documents Organizer was created to automate repetitive file-management tasks while still giving the user control over the folder being processed and visibility into what the application is doing. +> **Current version:** v0.2.0 +> **Current platform focus:** Windows + --- ## Features -- **Organize Files** — Organize files into folders based on file extension and modification date. -- **Flatten Folders** — Move files out of supported nested folder structures and back into their parent folders. -- **Custom Extensions** — Add additional file extensions to the flattening workflow. -- **Cancelable Operations** — Stop a flattening operation while it is in progress. -- **Folder Selection** — Select the directory that should be organized or flattened. -- **Explorer Integration** — Open selected folders directly in Windows File Explorer. -- **Activity Log** — Monitor file operations and application messages while an operation is running. +- **Organize Files:** Organize files into a date-first, file-type-based directory structure. +- **Flatten Folders:** Move files from organizer-generated date/type folders back into the selected root directory. +- **Nested Folder Support:** Recursively discover files inside nested directories and centralize them under the selected root. +- **Duplicate Protection:** Preserve files with duplicate names by automatically adding numbered suffixes instead of overwriting existing files. +- **Extensionless File Support:** Organize files without extensions into an `other` directory. +- **Cancelable Flattening:** Request cancellation of an active flatten operation. +- **Lazy-Loaded Folder Browser:** Load directory contents only as folders are expanded instead of recursively scanning the entire tree at startup. +- **Operation Target Selection:** Select a nested folder in the Folder Browser and use it as the target for file operations. +- **File Manager Integration:** Open selected folders directly in the platform file manager. +- **System Tray Support:** Explicitly minimize Documents Organizer to the system tray and restore or quit it from the tray menu. +- **Activity Log:** View timestamped operation messages, summaries, and errors. +- **Background File Operations:** Run file operations outside the Tkinter UI thread to keep the interface responsive. +- **Safe Directory Cleanup:** Remove only empty organizer-generated directories during flattening. +- **Automated Tests:** Filesystem, controller, presenter, folder browser, and complete workflow behavior are covered by automated tests. --- -## Screenshots +## What's New in v0.2.0 -### Main Application +v0.2.0 is a major rewrite of Documents Organizer. -Documents Organizer main application window +The release introduces a new organization structure, safer filesystem behavior, a redesigned interface, background operation management, lazy-loaded folder browsing, and a substantially refactored application architecture. -### Folder Selection +### New Organization Structure -Selecting a folder in Documents Organizer +Previous versions organized files using an extension-first structure. -### Adding Extensions +v0.2.0 uses a **date-first structure**: -Adding file extensions in Documents Organizer +```text +Selected Folder/ +├── YYYY-MM-DD/ +│ ├── pdf/ +│ ├── jpg/ +│ ├── txt/ +│ ├── zip/ +│ └── other/ +└── YYYY-MM-DD/ + └── ... +``` -### Flattening Folders +For example: -Flattening folders in Documents Organizer +```text +Documents/ +├── 2026-08-25/ +│ ├── pdf/ +│ │ └── report.pdf +│ ├── jpg/ +│ │ └── photo.jpg +│ └── txt/ +│ └── notes.txt +└── 2026-08-26/ + ├── zip/ + │ └── archive.zip + └── other/ + └── README +``` -### Organizing Files +The date is currently determined using each file's **modified date**. -Organizing files in Documents Organizer +Files without an extension are placed in: + +```text +other/ +``` + +--- + +## Screenshots + +The user interface was redesigned for v0.2.0. + +Updated screenshots will be added after the v0.2.0 Windows package has completed final release testing. + +The previous screenshots represented the older application interface and workflows and are no longer included here because they do not accurately represent v0.2.0. --- @@ -50,31 +98,78 @@ Documents Organizer was created to automate repetitive file-management tasks whi Documents Organizer is written in Python and uses Tkinter for its graphical interface. -Before running the application, make sure you have: +Development currently targets: + +```text +Python >= 3.12 +``` + +Runtime dependencies include: + +```text +pillow==10.2.0 +pystray==0.19.5 +``` + +Development dependencies include: + +```text +pytest>=8,<10 +``` -- Python installed -- The dependencies listed in `requirements.txt` -- Permission to read and modify the directories you intend to organize +You also need permission to read, move, and modify files within the directories you intend to process. + +--- ### Clone the Repository -Clone the repository from: +Clone the repository: + +```powershell +git clone https://github.com/DOS1986/Documents-Organizer.git +``` + +Enter the project directory: -`https://github.com/DOS1986/Documents-Organizer` +```powershell +cd Documents-Organizer +``` -Then navigate into the cloned project directory. +--- + +### Create a Virtual Environment -### Install Dependencies +On Windows: -Install the Python packages listed in: +```powershell +python -m venv .venv +``` -`requirements.txt` +Activate it: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +--- + +### Install the Project + +Install Documents Organizer in editable mode with development dependencies: + +```powershell +python -m pip install -e ".[dev]" +``` + +--- ### Run the Application -Run the application's main Python entry point. +From the project root: -The exact setup and execution commands can be added here once the current project structure has been reviewed and verified. +```powershell +python main.py +``` --- @@ -82,92 +177,728 @@ The exact setup and execution commands can be added here once the current projec ### Select a Folder -Choose the folder you want Documents Organizer to work with. +Choose **Select Folder** from the toolbar or File menu. -Always verify that the correct folder has been selected before starting an operation. +The selected directory becomes the application's **Root Folder**. -### Organize Files +The Folder Browser displays that directory and its immediate subdirectories. -Use the organize operation to sort files within the selected directory into folders based on file extension and modification date. +Always verify that the correct folder has been selected before beginning a filesystem operation. -### Flatten Folders +--- -Use the flatten operation to move files out of supported nested folder structures and back into their parent folders. +### Root Folder vs. Operation Target -### Add Extensions +Documents Organizer distinguishes between two locations: -Additional file extensions can be added through the application when files outside the default extension list need to be included in the flattening process. +- **Root Folder:** The folder originally selected using **Select Folder**. +- **Operation Target:** The folder currently selected in the Folder Browser. -### Cancel an Operation +When the root is initially loaded, both values point to the same directory. -A flattening operation can be canceled while it is running using the application's cancellation option. +Selecting a nested folder changes the Operation Target. -### Reveal in Explorer +Commands such as **Organize**, **Flatten**, and **Open Selected** operate on the current Operation Target. -Right-click a folder in the tree view to open that location directly in Windows File Explorer. +--- + +## Organizing Files + +Select the folder you want to organize and choose **Organize**. + +Documents Organizer recursively discovers files beneath the Operation Target and centralizes them into: + +```text +// +``` + +For example, starting with: + +```text +MyFiles/ +├── report.pdf +├── photo.jpg +└── project/ + └── notes.txt +``` + +Organize may produce: -### Monitor Operations +```text +MyFiles/ +├── project/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + ├── jpg/ + │ └── photo.jpg + └── txt/ + └── notes.txt +``` -Use the application's log to monitor progress, confirmation messages, and errors while file operations are being performed. +The original: + +```text +project/ +``` + +directory remains. + +v0.2.0 intentionally does **not** remove original source directories after their files have been centralized. --- -## Running a Packaged Release +## Nested Folder Centralization -Packaged Windows releases may be made available through the project's GitHub Releases page: +Files found in nested directories are organized into the selected Operation Target's date/type structure. -[Documents Organizer Releases](https://github.com/DOS1986/Documents-Organizer/releases) +For example: + +```text +Downloads/ +├── first/ +│ └── report.pdf +└── second/ + └── photo.jpg +``` + +may become: + +```text +Downloads/ +├── first/ +├── second/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + └── jpg/ + └── photo.jpg +``` + +The original nested hierarchy is not recreated inside the date directories. + +This is intentional. + +Documents Organizer is designed to **centralize files**, not preserve their original nested folder locations. + +--- + +## Duplicate Filename Protection + +Documents Organizer does not intentionally overwrite an existing destination file. + +When multiple files have the same name, numbered suffixes are automatically added. + +For example: + +```text +first/report.pdf +second/report.pdf +third/report.pdf +``` + +may become: + +```text +2026-08-26/ +└── pdf/ + ├── report.pdf + ├── report (1).pdf + └── report (2).pdf +``` + +The same collision protection is used when files are flattened back into the root directory. + +--- + +## Files Without Extensions + +Files without an extension are supported. + +For example: + +```text +README +LICENSE +Dockerfile +``` + +are organized beneath: + +```text +other/ +``` + +Example: + +```text +2026-08-26/ +└── other/ + ├── README + └── LICENSE +``` + +--- + +## Flattening Files + +**Flatten** reverses the organizer-generated date/type structure by moving eligible files back into the selected Operation Target. + +For example: + +```text +Documents/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + ├── jpg/ + │ └── photo.jpg + └── txt/ + └── notes.txt +``` + +becomes: + +```text +Documents/ +├── report.pdf +├── photo.jpg +└── notes.txt +``` + +After files are moved, Documents Organizer attempts to remove empty organizer-generated type and date directories. + +Directory cleanup is intentionally conservative. + +Documents Organizer uses empty-directory removal rather than recursive deletion. If unexpected content remains inside a directory, that directory is preserved. + +--- + +## Flatten Is Not Undo + +Flatten reverses the structure created by Documents Organizer, but it is **not a full undo system**. + +For example, suppose the original files were: + +```text +Documents/ +├── work/ +│ └── report.pdf +└── personal/ + └── photo.jpg +``` + +After organizing and then flattening, the result is: + +```text +Documents/ +├── work/ +├── personal/ +├── report.pdf +└── photo.jpg +``` + +Documents Organizer currently does not record enough information to know that: + +```text +report.pdf +``` + +originally belonged inside: + +```text +work/ +``` + +A future release may add an operation manifest and full undo support capable of restoring original file locations. + +--- + +## Canceling Flatten + +An active flatten operation can be canceled using the **Cancel** button or corresponding menu command. + +Cancellation is cooperative. + +When cancellation is requested, the application signals the active flatten operation and stops processing at a safe point. + +Files already moved before the cancellation request are not automatically moved back. + +--- + +## Folder Browser + +v0.2.0 introduces a lazy-loaded Folder Browser. + +When a root folder is selected, Documents Organizer loads only its immediate subdirectories. + +For example: + +```text +LargeFolder/ +├── Games/ +├── Photos/ +├── Projects/ +└── Work/ +``` + +Documents Organizer does not immediately scan every directory beneath those folders. + +Instead: + +```text +Select Folder + ↓ +Load immediate directories + ↓ +Display Folder Browser + ↓ +User expands Projects + ↓ +Load Projects children +``` + +This prevents the interface from recursively walking potentially very large directory structures merely to display the browser. + +Nested folders are loaded as they are expanded. + +The Folder Browser also attempts to preserve the current nested selection when the tree is refreshed without loading unrelated branches. + +--- + +## Open in File Manager + +The selected Operation Target can be opened directly from Documents Organizer. + +Use: -If no packaged release is currently available, the application can be run directly from the Python source. +```text +Open Selected +``` + +or right-click a folder in the Folder Browser and choose: + +```text +Open in File Manager +``` + +On Windows, the folder opens in File Explorer. + +The underlying file-manager integration is written with cross-platform support in mind. --- -## Configuration +## Activity Log + +Documents Organizer includes a timestamped Activity Log. + +Example: + +```text +[14:32:18] Documents Organizer v0.2.0 started. +[14:32:20] Selected folder: C:\Documents +[14:32:23] Organizing: C:\Documents +[14:32:24] Organized 4 pdf files. +[14:32:24] Organized 2 jpg files. +[14:32:24] Organization complete. 6 files moved. +``` + +The log reports: -Documents Organizer does not require external configuration for normal use. +- application startup +- folder selection +- operation start +- organization summaries +- flatten summaries +- skipped files +- operation failures +- cancellation requests +- operation completion -Additional file extensions used by the flattening process can be added through the application's interface. +Use **Clear Log** to reset the current Activity Log. --- -## Technology +## System Tray -Documents Organizer currently uses: +Documents Organizer can be explicitly minimized to the system tray using: -- Python -- Tkinter -- Local filesystem operations -- Windows File Explorer integration +```text +File → Minimize to Tray +``` + +The tray menu provides: + +```text +Show +Quit +``` + +**Show** restores the application window. + +**Quit** closes Documents Organizer. + +Clicking the normal Windows close button exits the application rather than silently minimizing it to the tray. --- ## File Safety -Documents Organizer performs operations that can move files and change directory structures. +Documents Organizer performs real filesystem operations that move files and modify directory structures. + +v0.2.0 contains several protections intended to make these operations safer: + +- Existing destination files are not intentionally overwritten. +- Duplicate filenames receive numbered suffixes. +- Files without extensions are supported. +- Common operating-system metadata files are ignored. +- Organizer-generated files are detected to prevent repeated reorganization. +- Flattening only processes supported organizer-generated date/type structures. +- Directory cleanup removes only directories that are actually empty. +- Unexpected contents prevent directories from being removed. +- Original source directories are preserved. +- Concurrent organize and flatten operations are prevented. +- Filesystem operations run outside the Tkinter UI thread. +- Flatten operations support cancellation. +- Automated workflow tests verify organize-to-flatten round trips. + +Ignored system metadata files currently include: + +```text +.DS_Store +Thumbs.db +``` + +### Important + +No filesystem utility can eliminate every possible risk. + +Before using Documents Organizer on important files: + +1. Keep an appropriate backup. +2. Test the application on a disposable or sample directory first. +3. Verify the selected Root Folder and Operation Target. +4. Avoid manually changing the same files while an operation is running. +5. Review the Activity Log after an operation completes. + +--- + +## Repeated Operations + +Documents Organizer is designed to make repeated operations safe. + +Running **Organize** again against an already organized directory should not create an increasingly nested organization structure. + +Running **Flatten** against a directory that has already been flattened should complete without moving unrelated root files. + +These behaviors are covered by automated workflow tests. + +--- + +## Project Structure + +v0.2.0 separates filesystem operations, application coordination, presentation logic, and user-interface components. + +```text +Documents-Organizer/ +├── documents_organizer/ +│ ├── __init__.py +│ ├── app.py +│ ├── filesystem.py +│ ├── platform_utils.py +│ ├── resources.py +│ ├── settings.py +│ │ +│ ├── controllers/ +│ │ ├── __init__.py +│ │ └── operation_controller.py +│ │ +│ ├── presenters/ +│ │ ├── __init__.py +│ │ └── operation_presenter.py +│ │ +│ ├── services/ +│ │ ├── __init__.py +│ │ ├── flattener.py +│ │ └── organizer.py +│ │ +│ └── ui/ +│ ├── __init__.py +│ ├── dialogs.py +│ ├── main_window.py +│ ├── styles.py +│ ├── tray_manager.py +│ │ +│ └── components/ +│ ├── __init__.py +│ ├── activity_log.py +│ ├── folder_browser.py +│ ├── folder_summary.py +│ ├── header.py +│ ├── menu_bar.py +│ ├── status_bar.py +│ └── toolbar.py +│ +├── images/ +├── tests/ +├── main.py +├── pyproject.toml +├── requirements.txt +├── README.md +└── LICENSE +``` + +--- + +## Architecture + +### Services + +Filesystem operations are implemented under: + +```text +documents_organizer/services/ +``` + +The main services are: + +```text +organizer.py +flattener.py +``` + +They perform filesystem operations without depending on Tkinter. + +--- + +### Operation Controller + +Background operation coordination lives in: + +```text +documents_organizer/controllers/operation_controller.py +``` + +The Operation Controller manages: + +- organizer worker threads +- flattener worker threads +- operation lifecycle +- busy state +- flatten cancellation +- worker result dispatch +- prevention of concurrent filesystem operations +- clean controller shutdown + +--- + +### Presenters + +Operation result formatting lives in: + +```text +documents_organizer/presenters/operation_presenter.py +``` + +The presenter converts organizer and flattener results into user-facing log and status messages. + +It does not manipulate Tkinter widgets directly. + +--- + +### UI Components + +Reusable interface components live under: + +```text +documents_organizer/ui/components/ +``` -Before using the application on important data: +These include: -- Keep a backup of the files being organized. -- Test the application on a small sample directory first. -- Verify that the correct folder has been selected before starting an operation. -- Review the application log while operations are running. -- Avoid manually modifying the same files or folders while Documents Organizer is processing them. +```text +ActivityLog +FolderBrowser +FolderSummary +Header +MenuBar +StatusBar +Toolbar +``` -Use the application carefully when working with files that do not have another backup. +`MainWindow` coordinates these components rather than implementing their internal behavior. + +--- + +### System Tray + +System tray integration is isolated in: + +```text +documents_organizer/ui/tray_manager.py +``` + +This keeps `pystray` implementation details outside the main application window. + +--- + +### Styles + +Tkinter/ttk style configuration is centralized in: + +```text +documents_organizer/ui/styles.py +``` + +--- + +## Testing + +Documents Organizer includes automated tests covering both individual application components and complete filesystem workflows. + +Run the complete test suite: + +```powershell +python -m pytest +``` + +Run with verbose output: + +```powershell +python -m pytest -v +``` + +Compile-check the application: + +```powershell +python -m compileall main.py documents_organizer +``` + +A useful pre-commit check is: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest +``` + +--- + +## Test Coverage + +The v0.2.0 test suite covers areas including: + +- safe filesystem moves +- destination collision handling +- numbered duplicate filenames +- ignored system files +- modified-date organization +- extension/type organization +- extensionless files +- nested file centralization +- already-organized file detection +- flattening +- safe empty-directory cleanup +- flatten cancellation +- operation controller state +- concurrent operation prevention +- worker result dispatch +- operation presenter output +- resource path resolution +- lazy-loaded folder browsing +- on-demand directory expansion +- nested selection restoration +- deleted-selection fallback +- complete organize-to-flatten workflows +- duplicate preservation across round trips +- extensionless file preservation across round trips +- repeated organize safety +- repeated flatten safety + +Filesystem tests use temporary directories rather than modifying real user folders. + +--- + +## Running a Packaged Release + +Packaged Windows releases are planned to be made available through the project's GitHub Releases page: + +[Documents Organizer Releases](https://github.com/DOS1986/Documents-Organizer/releases) + +Until an official v0.2.0 executable is published, Documents Organizer can be run directly from source. + +--- + +## Packaging Status + +The v0.2.0 application code and test suite are being prepared for packaged Windows distribution. + +The current source entry point is: + +```powershell +python main.py +``` + +PyInstaller packaging and automated build workflows are planned as part of the v0.2.0 release process. + +Do not assume a prebuilt binary is available until it appears on the GitHub Releases page. + +--- + +## Current Platform Support + +v0.2.0 development and release testing are currently focused on: + +```text +Windows +``` + +Some application infrastructure is already implemented with cross-platform support in mind, including file-manager launching and packaged-resource handling. + +Linux and macOS packaged builds have not yet completed validation and should not currently be considered officially supported. --- ## Troubleshooting -If you encounter a problem while using Documents Organizer: +If Documents Organizer does not behave as expected: -- Review the application log for error messages. -- Confirm that Python and the required dependencies are installed. -- Verify that your user account has permission to read and modify the selected directory. -- Make sure files being processed are not locked by another application. -- Try reproducing the issue using a small test directory. +- Review the Activity Log for errors. +- Confirm that the required dependencies are installed. +- Verify that the selected directory still exists. +- Confirm that your user account has permission to read and modify the selected directory. +- Check whether files are locked by another application. +- Try reproducing the issue using a small disposable directory. +- Run the automated test suite if working from source. - Review existing GitHub issues to see whether the problem has already been reported. +For source installations, useful diagnostic commands include: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest -v +python main.py +``` + +--- + +## Roadmap + +Potential future improvements include: + +- Dry-run / preview mode before moving files +- Operation manifests +- Full undo support +- Original-location restoration +- Optional cleanup of original empty source directories +- Configurable organization strategies +- Configurable organization rules +- Additional file metadata options +- Expanded cross-platform testing +- Linux packaging +- macOS packaging +- Automated release builds +- Additional packaged release formats + --- ## Contributing @@ -178,8 +909,18 @@ If you would like to contribute: 1. Fork the repository. 2. Create a branch for your change. -3. Make and test your changes. -4. Submit a pull request describing what was changed and why. +3. Install the development dependencies. +4. Make your changes. +5. Run the complete test suite. +6. Confirm the application still launches correctly. +7. Submit a pull request describing what changed and why. + +Before submitting a pull request, run: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest +``` --- @@ -195,9 +936,11 @@ When reporting a bug, please include: - What happened - What you expected to happen - Steps that reproduce the issue -- Any relevant application log output +- Your operating system +- Your Python version if running from source +- Any relevant Activity Log output -Please avoid including private file names, paths, or other sensitive information in public issue reports. +Please avoid including private file names, personal directory paths, or other sensitive information in public issue reports. --- @@ -211,7 +954,9 @@ Documents Organizer is licensed under the [MIT License](LICENSE). Documents Organizer is provided as-is without warranty. -The application performs filesystem operations that may move files and modify directory structures. Users are responsible for maintaining appropriate backups and verifying the selected directory before performing an operation. +The application performs filesystem operations that may move files and modify directory structures. + +Users are responsible for maintaining appropriate backups and verifying the selected directory before performing an operation. See the [MIT License](LICENSE) for the project's licensing terms. @@ -223,4 +968,4 @@ Created by [David O. Southwood](https://davidosouthwood.com). - [Website](https://davidosouthwood.com) - [GitHub](https://github.com/DOS1986) -- [LinkedIn](https://www.linkedin.com/in/davidsouthwood/) +- [LinkedIn](https://www.linkedin.com/in/davidsouthwood/) \ No newline at end of file From c1a7b4be399ed564fd8502be089cc6895e76fac0 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Thu, 27 Aug 2026 07:22:13 -0400 Subject: [PATCH 16/20] Add Windows packaging configuration - add PyInstaller as a development dependency - add reproducible Windows onedir build specification - bundle application image resources with packaged builds - configure the Windows application icon - build the application without a console window - exclude generated build and distribution directories from Git - verify folder browsing, file operations, and system tray behavior in the packaged application --- .gitignore | 6 +++- DocumentsOrganizer.spec | 78 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 DocumentsOrganizer.spec diff --git a/.gitignore b/.gitignore index 429a05c..efe779c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec +#*.spec # Installer logs pip-log.txt @@ -160,3 +160,7 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ .pytest_tmp/ + +# PyInstaller +/build/ +/dist/ diff --git a/DocumentsOrganizer.spec b/DocumentsOrganizer.spec new file mode 100644 index 0000000..4207e33 --- /dev/null +++ b/DocumentsOrganizer.spec @@ -0,0 +1,78 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + + +project_root = Path(SPECPATH) + +app_name = "DocumentsOrganizer" + +entry_point = project_root / "main.py" + +icon_path = ( + project_root + / "images" + / "folder-256.ico" +) + +images_path = ( + project_root + / "images" +) + + +a = Analysis( + [str(entry_point)], + pathex=[ + str(project_root), + ], + binaries=[], + datas=[ + ( + str(images_path), + "images", + ), + ], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) + +pyz = PYZ( + a.pure +) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=app_name, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[ + str(icon_path), + ], +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=app_name, +) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d62e795..3ba177a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8,<10", + "pyinstaller==6.22.2", ] [tool.setuptools.packages.find] From 22874ca2f0519d5411f230f2a4728ca5204e2dd9 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Thu, 27 Aug 2026 07:27:22 -0400 Subject: [PATCH 17/20] Add Windows executable metadata - add Windows version information for packaged builds - set v0.2.0 product and file versions - add product name and file description metadata - add copyright and original filename information - configure PyInstaller to embed version resources - verify metadata in the packaged Windows executable --- DocumentsOrganizer.spec | 7 ++++ packaging/windows/version_info.txt | 62 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packaging/windows/version_info.txt diff --git a/DocumentsOrganizer.spec b/DocumentsOrganizer.spec index 4207e33..df9dc56 100644 --- a/DocumentsOrganizer.spec +++ b/DocumentsOrganizer.spec @@ -20,6 +20,12 @@ images_path = ( / "images" ) +version_info_path = ( + project_root + / "packaging" + / "windows" + / "version_info.txt" +) a = Analysis( [str(entry_point)], @@ -62,6 +68,7 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + version=str(version_info_path), icon=[ str(icon_path), ], diff --git a/packaging/windows/version_info.txt b/packaging/windows/version_info.txt new file mode 100644 index 0000000..2bc8190 --- /dev/null +++ b/packaging/windows/version_info.txt @@ -0,0 +1,62 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(0, 2, 0, 0), + prodvers=(0, 2, 0, 0), + mask=0x3F, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo( + [ + StringTable( + "040904B0", + [ + StringStruct( + "FileDescription", + "Documents Organizer", + ), + StringStruct( + "FileVersion", + "0.2.0.0", + ), + StringStruct( + "InternalName", + "DocumentsOrganizer", + ), + StringStruct( + "LegalCopyright", + "Copyright © 2026 David O. Southwood", + ), + StringStruct( + "OriginalFilename", + "DocumentsOrganizer.exe", + ), + StringStruct( + "ProductName", + "Documents Organizer", + ), + StringStruct( + "ProductVersion", + "0.2.0", + ), + ], + ) + ] + ), + VarFileInfo( + [ + VarStruct( + "Translation", + [ + 1033, + 1200, + ], + ) + ] + ), + ], +) \ No newline at end of file From 97b76c95461f1289b0675b56c127c8914475adba Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Sat, 29 Aug 2026 00:50:49 -0400 Subject: [PATCH 18/20] Add repeatable Windows release build - add PowerShell release build automation - validate application version consistency before packaging - compile, check source before release builds - run the complete automated test suite - clean previous PyInstaller build output - build the Windows application from the committed spec - validate packaged executable version metadata - create versioned Windows x64 release archives - generate SHA-256 checksums for release artifacts - exclude generated release artifacts from Git --- .gitignore | 3 + scripts/build-windows.ps1 | 508 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 scripts/build-windows.ps1 diff --git a/.gitignore b/.gitignore index efe779c..9213e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,6 @@ cython_debug/ # PyInstaller /build/ /dist/ + +# Release artifacts +/artifacts/ \ No newline at end of file diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..063c4f0 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,508 @@ +param( + [string]$Python = "python", + [switch]$SkipTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + + +# ----------------------------------------------------------------------------- +# Paths +# ----------------------------------------------------------------------------- + +$RepoRoot = Split-Path -Parent $PSScriptRoot + +$PackageInitPath = Join-Path ` + $RepoRoot ` + "documents_organizer\__init__.py" + +$PyProjectPath = Join-Path ` + $RepoRoot ` + "pyproject.toml" + +$VersionInfoPath = Join-Path ` + $RepoRoot ` + "packaging\windows\version_info.txt" + +$SpecPath = Join-Path ` + $RepoRoot ` + "DocumentsOrganizer.spec" + +$BuildDirectory = Join-Path ` + $RepoRoot ` + "build" + +$DistDirectory = Join-Path ` + $RepoRoot ` + "dist" + +$ApplicationDirectory = Join-Path ` + $DistDirectory ` + "DocumentsOrganizer" + +$ExecutablePath = Join-Path ` + $ApplicationDirectory ` + "DocumentsOrganizer.exe" + +$ArtifactsDirectory = Join-Path ` + $RepoRoot ` + "artifacts" + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +function Write-Step { + param( + [string]$Message + ) + + Write-Host "" + Write-Host "============================================================" + Write-Host $Message + Write-Host "============================================================" +} + + +function Invoke-Python { + param( + [string[]]$Arguments + ) + + & $Python @Arguments + + if ($LASTEXITCODE -ne 0) { + throw ( + "Python command failed with exit code " + + "$LASTEXITCODE." + ) + } +} + + +function Get-RegexValue { + param( + [string]$Path, + [string]$Pattern, + [string]$Description + ) + + if (-not (Test-Path $Path)) { + throw "Required file not found: $Path" + } + + $Content = Get-Content ` + $Path ` + -Raw + + $Match = [regex]::Match( + $Content, + $Pattern + ) + + if (-not $Match.Success) { + throw ( + "Unable to determine $Description " + + "from $Path." + ) + } + + return $Match.Groups[1].Value +} + + +# ----------------------------------------------------------------------------- +# Environment validation +# ----------------------------------------------------------------------------- + +Write-Step "Validating build environment" + +if ($env:OS -ne "Windows_NT") { + throw ( + "Windows release builds must be created on Windows." + ) +} + +if (-not [Environment]::Is64BitProcess) { + throw ( + "Documents Organizer Windows releases must be built " + + "using a 64-bit Python environment." + ) +} + +Push-Location $RepoRoot + +try { + & $Python --version + + if ($LASTEXITCODE -ne 0) { + throw ( + "Unable to execute Python using '$Python'." + ) + } + + Write-Host "" + Write-Host "Repository:" + Write-Host " $RepoRoot" + + + # ------------------------------------------------------------------------- + # Version validation + # ------------------------------------------------------------------------- + + Write-Step "Validating application version" + + $PackageVersion = Get-RegexValue ` + -Path $PackageInitPath ` + -Pattern '__version__\s*=\s*"([^"]+)"' ` + -Description "package version" + + $ProjectVersion = Get-RegexValue ` + -Path $PyProjectPath ` + -Pattern '(?m)^version\s*=\s*"([^"]+)"' ` + -Description "pyproject version" + + $WindowsProductVersion = Get-RegexValue ` + -Path $VersionInfoPath ` + -Pattern '(?s)StringStruct\(\s*"ProductVersion",\s*"([^"]+)"' ` + -Description "Windows product version" + + Write-Host "Package version:" + Write-Host " $PackageVersion" + + Write-Host "pyproject version:" + Write-Host " $ProjectVersion" + + Write-Host "Windows product version:" + Write-Host " $WindowsProductVersion" + + if ( + $PackageVersion -ne $ProjectVersion -or + $PackageVersion -ne $WindowsProductVersion + ) { + throw ( + "Application version mismatch detected. " + + "Update all version locations before building." + ) + } + + $Version = $PackageVersion + + + # ------------------------------------------------------------------------- + # Git information + # ------------------------------------------------------------------------- + + Write-Step "Checking repository state" + + $GitCommit = ( + git rev-parse --short HEAD + ).Trim() + + if ($LASTEXITCODE -ne 0) { + throw "Unable to determine the Git commit." + } + + Write-Host "Git commit:" + Write-Host " $GitCommit" + + $GitStatus = git status --porcelain + + if ($LASTEXITCODE -ne 0) { + throw "Unable to determine Git repository status." + } + + if ($GitStatus) { + Write-Warning ( + "The working tree contains uncommitted changes. " + + "The build can continue, but official release " + + "candidates should normally be built from a clean tree." + ) + } + else { + Write-Host "Working tree is clean." + } + + + # ------------------------------------------------------------------------- + # Compile validation + # ------------------------------------------------------------------------- + + Write-Step "Compile-checking application" + + Invoke-Python -Arguments @( + "-m", + "compileall", + "main.py", + "documents_organizer" + ) + + + # ------------------------------------------------------------------------- + # Automated tests + # ------------------------------------------------------------------------- + + if (-not $SkipTests) { + Write-Step "Running automated tests" + + Invoke-Python -Arguments @( + "-m", + "pytest" + ) + } + else { + Write-Warning "Automated tests were skipped." + } + + + # ------------------------------------------------------------------------- + # Clean previous PyInstaller output + # ------------------------------------------------------------------------- + + Write-Step "Cleaning previous build output" + + if (Test-Path $BuildDirectory) { + Remove-Item ` + $BuildDirectory ` + -Recurse ` + -Force + } + + if (Test-Path $DistDirectory) { + Remove-Item ` + $DistDirectory ` + -Recurse ` + -Force + } + + Write-Host "Previous build output removed." + + + # ------------------------------------------------------------------------- + # PyInstaller + # ------------------------------------------------------------------------- + + Write-Step "Building Documents Organizer v$Version" + + Invoke-Python -Arguments @( + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + $SpecPath + ) + + if (-not (Test-Path $ExecutablePath)) { + throw ( + "PyInstaller completed but the expected executable " + + "was not found:`n$ExecutablePath" + ) + } + + Write-Host "" + Write-Host "Executable created:" + Write-Host " $ExecutablePath" + + + # ------------------------------------------------------------------------- + # Executable metadata validation + # ------------------------------------------------------------------------- + + Write-Step "Validating Windows executable metadata" + + $ExecutableVersion = ( + Get-Item $ExecutablePath + ).VersionInfo + + if ( + $ExecutableVersion.ProductName -ne + "Documents Organizer" + ) { + throw ( + "Unexpected ProductName in Windows executable." + ) + } + + if ( + $ExecutableVersion.ProductVersion -ne + $Version + ) { + throw ( + "Executable ProductVersion does not match " + + "application version." + ) + } + + if ( + $ExecutableVersion.FileDescription -ne + "Documents Organizer" + ) { + throw ( + "Unexpected FileDescription in Windows executable." + ) + } + + Write-Host "File description:" + Write-Host ( + " " + + $ExecutableVersion.FileDescription + ) + + Write-Host "File version:" + Write-Host ( + " " + + $ExecutableVersion.FileVersion + ) + + Write-Host "Product version:" + Write-Host ( + " " + + $ExecutableVersion.ProductVersion + ) + + + # ------------------------------------------------------------------------- + # Release artifact + # ------------------------------------------------------------------------- + + Write-Step "Creating release artifact" + + if (-not (Test-Path $ArtifactsDirectory)) { + New-Item ` + -ItemType Directory ` + -Path $ArtifactsDirectory ` + | Out-Null + } + + $ArtifactName = ( + "DocumentsOrganizer-v" + + $Version + + "-windows-x64" + ) + + $StagingDirectory = Join-Path ` + $ArtifactsDirectory ` + $ArtifactName + + $ZipPath = Join-Path ` + $ArtifactsDirectory ` + ($ArtifactName + ".zip") + + $HashPath = ( + $ZipPath + + ".sha256.txt" + ) + + if (Test-Path $StagingDirectory) { + Remove-Item ` + $StagingDirectory ` + -Recurse ` + -Force + } + + if (Test-Path $ZipPath) { + Remove-Item ` + $ZipPath ` + -Force + } + + if (Test-Path $HashPath) { + Remove-Item ` + $HashPath ` + -Force + } + + New-Item ` + -ItemType Directory ` + -Path $StagingDirectory ` + | Out-Null + + Copy-Item ` + -Path $ApplicationDirectory ` + -Destination $StagingDirectory ` + -Recurse + + Add-Type ` + -AssemblyName System.IO.Compression.FileSystem + + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $StagingDirectory, + $ZipPath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) + + Remove-Item ` + $StagingDirectory ` + -Recurse ` + -Force + + if (-not (Test-Path $ZipPath)) { + throw ( + "Release ZIP was not created successfully." + ) + } + + + # ------------------------------------------------------------------------- + # SHA-256 + # ------------------------------------------------------------------------- + + Write-Step "Calculating SHA-256 checksum" + + $Hash = Get-FileHash ` + $ZipPath ` + -Algorithm SHA256 + + $HashText = ( + $Hash.Hash.ToLowerInvariant() + + " " + + [System.IO.Path]::GetFileName( + $ZipPath + ) + ) + + Set-Content ` + -Path $HashPath ` + -Value $HashText ` + -Encoding ASCII + + Write-Host $HashText + + + # ------------------------------------------------------------------------- + # Complete + # ------------------------------------------------------------------------- + + Write-Step "Windows release build complete" + + Write-Host "Version:" + Write-Host " $Version" + + Write-Host "" + Write-Host "Git commit:" + Write-Host " $GitCommit" + + Write-Host "" + Write-Host "Application:" + Write-Host " $ExecutablePath" + + Write-Host "" + Write-Host "Release ZIP:" + Write-Host " $ZipPath" + + Write-Host "" + Write-Host "SHA-256:" + Write-Host " $HashPath" + + Write-Host "" + Write-Host ( + "Documents Organizer v$Version " + + "Windows build completed successfully." + ) +} +finally { + Pop-Location +} \ No newline at end of file From 74c7c3378719c2976a384af746cc253efaca828a Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Sat, 29 Aug 2026 00:57:50 -0400 Subject: [PATCH 19/20] Prepare v0.2.0 release candidate 1 - set the application version to 0.2.0rc1 - assign Windows build version 0.2.0.1 - distinguish package and release version formats - generate human-readable RC artifact filenames - preserve PEP 440-compatible Python versioning - prepare Windows release artifacts for RC1 validation --- documents_organizer/__init__.py | 2 +- packaging/windows/version_info.txt | 4 ++-- pyproject.toml | 2 +- scripts/build-windows.ps1 | 19 +++++++++++++++++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/documents_organizer/__init__.py b/documents_organizer/__init__.py index aa4b5ea..c6f0d2b 100644 --- a/documents_organizer/__init__.py +++ b/documents_organizer/__init__.py @@ -1,3 +1,3 @@ """Documents Organizer application package.""" -__version__ = "0.2.0" \ No newline at end of file +__version__ = "0.2.0rc1" \ No newline at end of file diff --git a/packaging/windows/version_info.txt b/packaging/windows/version_info.txt index 2bc8190..f3dea2e 100644 --- a/packaging/windows/version_info.txt +++ b/packaging/windows/version_info.txt @@ -21,7 +21,7 @@ VSVersionInfo( ), StringStruct( "FileVersion", - "0.2.0.0", + "0.2.0.1", ), StringStruct( "InternalName", @@ -41,7 +41,7 @@ VSVersionInfo( ), StringStruct( "ProductVersion", - "0.2.0", + "0.2.0rc1", ), ], ) diff --git a/pyproject.toml b/pyproject.toml index 3ba177a..28021f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "documents-organizer" -version = "0.2.0" +version = "0.2.0rc1" description = "A desktop utility for organizing and flattening file collections." requires-python = ">=3.12" dependencies = [ diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 063c4f0..be4e7f1 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -190,6 +190,17 @@ try { $Version = $PackageVersion + $ReleaseVersion = $Version + + if ( + $Version -match '^(\d+\.\d+\.\d+)rc(\d+)$' + ) { + $ReleaseVersion = ( + $Matches[1] + + "-rc" + + $Matches[2] + ) + } # ------------------------------------------------------------------------- # Git information @@ -378,7 +389,7 @@ try { $ArtifactName = ( "DocumentsOrganizer-v" + - $Version + + $ReleaseVersion + "-windows-x64" ) @@ -478,9 +489,13 @@ try { Write-Step "Windows release build complete" - Write-Host "Version:" + Write-Host "Application version:" Write-Host " $Version" + Write-Host "" + Write-Host "Release version:" + Write-Host " $ReleaseVersion" + Write-Host "" Write-Host "Git commit:" Write-Host " $GitCommit" From fc108adfd8033dbe8846590fd9504885085a50b9 Mon Sep 17 00:00:00 2001 From: DOS1986 Date: Sat, 29 Aug 2026 01:14:07 -0400 Subject: [PATCH 20/20] Add GitHub Actions CI workflows - run compile and test validation on supported Python versions - test the application on Python 3.12 and 3.13 - add automated Windows x64 release builds - reuse the committed Windows release build script in CI - validate packaged executable metadata during builds - generate versioned Windows release archives and checksums - upload Windows build artifacts for validation - add workflow concurrency and least-privilege permissions --- .github/workflows/test.yml | 66 ++++++ .github/workflows/windows-build.yml | 66 ++++++ packaging/windows/version_info.txt | 4 +- scripts/build-windows.ps1 | 345 ++++++++++++++++++++++++---- 4 files changed, 436 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/windows-build.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..374378e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,66 @@ +name: Tests + +on: + push: + branches: + - main + - "feature/**" + + pull_request: + branches: + - main + + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} + + runs-on: windows-latest + + timeout-minutes: 15 + + strategy: + fail-fast: false + + matrix: + python-version: + - "3.12" + - "3.13" + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + cache: pip + cache-dependency-path: pyproject.toml + + - name: Show Python version + run: python --version + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install project + run: python -m pip install -e ".[dev]" + + - name: Compile-check source + run: > + python -m compileall + main.py + documents_organizer + + - name: Run tests + run: python -m pytest -v \ No newline at end of file diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..d3f38bf --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,66 @@ +name: Windows Build + +on: + pull_request: + branches: + - main + + push: + tags: + - "v*" + + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build Windows x64 + + runs-on: windows-latest + + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13.15" + architecture: x64 + cache: pip + cache-dependency-path: pyproject.toml + + - name: Show Python version + run: python --version + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install project + run: python -m pip install -e ".[dev]" + + - name: Build Windows release artifact + shell: pwsh + run: .\scripts\build-windows.ps1 + + - name: List release artifacts + shell: pwsh + run: Get-ChildItem .\artifacts\ + + - name: Upload Windows release artifacts + uses: actions/upload-artifact@v7 + with: + name: documents-organizer-windows-x64 + path: | + artifacts/*.zip + artifacts/*.sha256.txt + if-no-files-found: error + retention-days: 14 \ No newline at end of file diff --git a/packaging/windows/version_info.txt b/packaging/windows/version_info.txt index f3dea2e..0e92261 100644 --- a/packaging/windows/version_info.txt +++ b/packaging/windows/version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(0, 2, 0, 0), - prodvers=(0, 2, 0, 0), + filevers=(0, 2, 0, 1), + prodvers=(0, 2, 0, 1), mask=0x3F, flags=0x0, OS=0x40004, diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index be4e7f1..b44ce88 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -113,6 +113,60 @@ function Get-RegexValue { } +function Get-WindowsFixedVersion { + param( + [string]$Path, + [string]$FieldName + ) + + if (-not (Test-Path $Path)) { + throw "Required file not found: $Path" + } + + $Content = Get-Content ` + $Path ` + -Raw + + $EscapedFieldName = ( + [regex]::Escape( + $FieldName + ) + ) + + $Pattern = ( + $EscapedFieldName + + '\s*=\s*\(' + + '\s*(\d+)\s*,' + + '\s*(\d+)\s*,' + + '\s*(\d+)\s*,' + + '\s*(\d+)\s*' + + '\)' + ) + + $Match = [regex]::Match( + $Content, + $Pattern + ) + + if (-not $Match.Success) { + throw ( + "Unable to determine Windows " + + "$FieldName from $Path." + ) + } + + return ( + $Match.Groups[1].Value + + "." + + $Match.Groups[2].Value + + "." + + $Match.Groups[3].Value + + "." + + $Match.Groups[4].Value + ) +} + + # ----------------------------------------------------------------------------- # Environment validation # ----------------------------------------------------------------------------- @@ -149,7 +203,7 @@ try { # ------------------------------------------------------------------------- - # Version validation + # Application version # ------------------------------------------------------------------------- Write-Step "Validating application version" @@ -164,44 +218,174 @@ try { -Pattern '(?m)^version\s*=\s*"([^"]+)"' ` -Description "pyproject version" + if ($PackageVersion -ne $ProjectVersion) { + throw ( + "Application version mismatch detected.`n" + + "Package version: $PackageVersion`n" + + "pyproject version: $ProjectVersion" + ) + } + + $Version = $PackageVersion + + + # ------------------------------------------------------------------------- + # Release and Windows version calculation + # ------------------------------------------------------------------------- + + $ReleaseVersion = $Version + + $VersionMajor = $null + $VersionMinor = $null + $VersionPatch = $null + $VersionBuild = $null + + if ( + $Version -match + '^(\d+)\.(\d+)\.(\d+)rc(\d+)$' + ) { + $VersionMajor = [int]$Matches[1] + $VersionMinor = [int]$Matches[2] + $VersionPatch = [int]$Matches[3] + $VersionBuild = [int]$Matches[4] + + $ReleaseVersion = ( + "$VersionMajor." + + "$VersionMinor." + + "$VersionPatch" + + "-rc" + + "$VersionBuild" + ) + } + elseif ( + $Version -match + '^(\d+)\.(\d+)\.(\d+)$' + ) { + $VersionMajor = [int]$Matches[1] + $VersionMinor = [int]$Matches[2] + $VersionPatch = [int]$Matches[3] + $VersionBuild = 0 + } + else { + throw ( + "Unsupported application version format: $Version`n" + + "Expected a version such as 0.2.0 or 0.2.0rc1." + ) + } + + $ExpectedWindowsVersion = ( + "$VersionMajor." + + "$VersionMinor." + + "$VersionPatch." + + "$VersionBuild" + ) + + Write-Host "Application version:" + Write-Host " $Version" + + Write-Host "Release version:" + Write-Host " $ReleaseVersion" + + Write-Host "Expected Windows version:" + Write-Host " $ExpectedWindowsVersion" + + + # ------------------------------------------------------------------------- + # Windows source metadata validation + # ------------------------------------------------------------------------- + + Write-Step "Validating Windows version metadata" + $WindowsProductVersion = Get-RegexValue ` -Path $VersionInfoPath ` - -Pattern '(?s)StringStruct\(\s*"ProductVersion",\s*"([^"]+)"' ` - -Description "Windows product version" - - Write-Host "Package version:" - Write-Host " $PackageVersion" + -Pattern ( + '(?s)StringStruct\(' + + '\s*"ProductVersion",' + + '\s*"([^"]+)"' + ) ` + -Description "Windows ProductVersion" + + $WindowsFileVersion = Get-RegexValue ` + -Path $VersionInfoPath ` + -Pattern ( + '(?s)StringStruct\(' + + '\s*"FileVersion",' + + '\s*"([^"]+)"' + ) ` + -Description "Windows FileVersion" + + $WindowsFixedFileVersion = Get-WindowsFixedVersion ` + -Path $VersionInfoPath ` + -FieldName "filevers" - Write-Host "pyproject version:" - Write-Host " $ProjectVersion" + $WindowsFixedProductVersion = Get-WindowsFixedVersion ` + -Path $VersionInfoPath ` + -FieldName "prodvers" - Write-Host "Windows product version:" + Write-Host "ProductVersion string:" Write-Host " $WindowsProductVersion" + Write-Host "FileVersion string:" + Write-Host " $WindowsFileVersion" + + Write-Host "Fixed filevers:" + Write-Host " $WindowsFixedFileVersion" + + Write-Host "Fixed prodvers:" + Write-Host " $WindowsFixedProductVersion" + if ( - $PackageVersion -ne $ProjectVersion -or - $PackageVersion -ne $WindowsProductVersion + $WindowsProductVersion -ne + $Version ) { throw ( - "Application version mismatch detected. " + - "Update all version locations before building." + "Windows ProductVersion does not match " + + "the application version.`n" + + "Expected: $Version`n" + + "Actual: $WindowsProductVersion" ) } - $Version = $PackageVersion + if ( + $WindowsFileVersion -ne + $ExpectedWindowsVersion + ) { + throw ( + "Windows FileVersion string does not match " + + "the expected numeric Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $WindowsFileVersion" + ) + } - $ReleaseVersion = $Version + if ( + $WindowsFixedFileVersion -ne + $ExpectedWindowsVersion + ) { + throw ( + "FixedFileInfo filevers does not match " + + "the expected Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $WindowsFixedFileVersion" + ) + } if ( - $Version -match '^(\d+\.\d+\.\d+)rc(\d+)$' + $WindowsFixedProductVersion -ne + $ExpectedWindowsVersion ) { - $ReleaseVersion = ( - $Matches[1] + - "-rc" + - $Matches[2] + throw ( + "FixedFileInfo prodvers does not match " + + "the expected Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $WindowsFixedProductVersion" ) } + Write-Host "" + Write-Host "Windows source metadata is consistent." + + # ------------------------------------------------------------------------- # Git information # ------------------------------------------------------------------------- @@ -295,7 +479,7 @@ try { # PyInstaller # ------------------------------------------------------------------------- - Write-Step "Building Documents Organizer v$Version" + Write-Step "Building Documents Organizer v$ReleaseVersion" Invoke-Python -Arguments @( "-m", @@ -327,12 +511,69 @@ try { Get-Item $ExecutablePath ).VersionInfo + $ExecutableFixedFileVersion = ( + "$($ExecutableVersion.FileMajorPart)." + + "$($ExecutableVersion.FileMinorPart)." + + "$($ExecutableVersion.FileBuildPart)." + + "$($ExecutableVersion.FilePrivatePart)" + ) + + $ExecutableFixedProductVersion = ( + "$($ExecutableVersion.ProductMajorPart)." + + "$($ExecutableVersion.ProductMinorPart)." + + "$($ExecutableVersion.ProductBuildPart)." + + "$($ExecutableVersion.ProductPrivatePart)" + ) + + Write-Host "File description:" + Write-Host ( + " " + + $ExecutableVersion.FileDescription + ) + + Write-Host "FileVersion string:" + Write-Host ( + " " + + $ExecutableVersion.FileVersion + ) + + Write-Host "Fixed FileVersion:" + Write-Host ( + " " + + $ExecutableFixedFileVersion + ) + + Write-Host "ProductVersion string:" + Write-Host ( + " " + + $ExecutableVersion.ProductVersion + ) + + Write-Host "Fixed ProductVersion:" + Write-Host ( + " " + + $ExecutableFixedProductVersion + ) + if ( $ExecutableVersion.ProductName -ne "Documents Organizer" ) { throw ( - "Unexpected ProductName in Windows executable." + "Unexpected ProductName in Windows executable.`n" + + "Expected: Documents Organizer`n" + + "Actual: $($ExecutableVersion.ProductName)" + ) + } + + if ( + $ExecutableVersion.FileDescription -ne + "Documents Organizer" + ) { + throw ( + "Unexpected FileDescription in Windows executable.`n" + + "Expected: Documents Organizer`n" + + "Actual: $($ExecutableVersion.FileDescription)" ) } @@ -341,37 +582,51 @@ try { $Version ) { throw ( - "Executable ProductVersion does not match " + - "application version." + "Executable ProductVersion string does not match " + + "the application version.`n" + + "Expected: $Version`n" + + "Actual: $($ExecutableVersion.ProductVersion)" ) } if ( - $ExecutableVersion.FileDescription -ne - "Documents Organizer" + $ExecutableVersion.FileVersion -ne + $ExpectedWindowsVersion ) { throw ( - "Unexpected FileDescription in Windows executable." + "Executable FileVersion string does not match " + + "the expected Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $($ExecutableVersion.FileVersion)" ) } - Write-Host "File description:" - Write-Host ( - " " + - $ExecutableVersion.FileDescription - ) + if ( + $ExecutableFixedFileVersion -ne + $ExpectedWindowsVersion + ) { + throw ( + "Executable fixed FileVersion does not match " + + "the expected Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $ExecutableFixedFileVersion" + ) + } - Write-Host "File version:" - Write-Host ( - " " + - $ExecutableVersion.FileVersion - ) + if ( + $ExecutableFixedProductVersion -ne + $ExpectedWindowsVersion + ) { + throw ( + "Executable fixed ProductVersion does not match " + + "the expected Windows version.`n" + + "Expected: $ExpectedWindowsVersion`n" + + "Actual: $ExecutableFixedProductVersion" + ) + } - Write-Host "Product version:" - Write-Host ( - " " + - $ExecutableVersion.ProductVersion - ) + Write-Host "" + Write-Host "Executable metadata is valid." # ------------------------------------------------------------------------- @@ -496,6 +751,10 @@ try { Write-Host "Release version:" Write-Host " $ReleaseVersion" + Write-Host "" + Write-Host "Windows version:" + Write-Host " $ExpectedWindowsVersion" + Write-Host "" Write-Host "Git commit:" Write-Host " $GitCommit" @@ -514,7 +773,7 @@ try { Write-Host "" Write-Host ( - "Documents Organizer v$Version " + + "Documents Organizer v$ReleaseVersion " + "Windows build completed successfully." ) }