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/.gitignore b/.gitignore
index 7b2c3ac..9213e2b 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
@@ -158,4 +158,12 @@ 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/
+
+# PyInstaller
+/build/
+/dist/
+
+# Release artifacts
+/artifacts/
\ No newline at end of file
diff --git a/DocumentsOrganizer.spec b/DocumentsOrganizer.spec
new file mode 100644
index 0000000..df9dc56
--- /dev/null
+++ b/DocumentsOrganizer.spec
@@ -0,0 +1,85 @@
+# -*- 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"
+)
+
+version_info_path = (
+ project_root
+ / "packaging"
+ / "windows"
+ / "version_info.txt"
+)
+
+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,
+ version=str(version_info_path),
+ 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/README.md b/README.md
index 74269cf..af248f3 100644
--- a/README.md
+++ b/README.md
@@ -2,45 +2,93 @@

-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.
-
+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
-
+Previous versions organized files using an extension-first structure.
-### Adding Extensions
+v0.2.0 uses a **date-first structure**:
-
+```text
+Selected Folder/
+├── YYYY-MM-DD/
+│ ├── pdf/
+│ ├── jpg/
+│ ├── txt/
+│ ├── zip/
+│ └── other/
+└── YYYY-MM-DD/
+ └── ...
+```
-### Flattening Folders
+For example:
-
+```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**.
-
+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
diff --git a/documents_organizer/__init__.py b/documents_organizer/__init__.py
new file mode 100644
index 0000000..c6f0d2b
--- /dev/null
+++ b/documents_organizer/__init__.py
@@ -0,0 +1,3 @@
+"""Documents Organizer application package."""
+
+__version__ = "0.2.0rc1"
\ No newline at end of file
diff --git a/documents_organizer/app.py b/documents_organizer/app.py
new file mode 100644
index 0000000..cf894b2
--- /dev/null
+++ b/documents_organizer/app.py
@@ -0,0 +1,14 @@
+import tkinter as tk
+
+from documents_organizer.ui.main_window import MainWindow
+
+
+def run() -> None:
+ """Start Documents Organizer."""
+ root = tk.Tk()
+
+ app = MainWindow(
+ root
+ )
+
+ root.mainloop()
\ No newline at end of file
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/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/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
new file mode 100644
index 0000000..fc4784c
--- /dev/null
+++ 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/resources.py b/documents_organizer/resources.py
new file mode 100644
index 0000000..0e77c64
--- /dev/null
+++ 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/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..e41c841
--- /dev/null
+++ 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/documents_organizer/services/organizer.py b/documents_organizer/services/organizer.py
new file mode 100644
index 0000000..2ce2eb6
--- /dev/null
+++ b/documents_organizer/services/organizer.py
@@ -0,0 +1,273 @@
+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 all files beneath a directory by modified date and file type.
+
+ Files from nested directories are centralized into the selected root.
+
+ Example:
+
+ Downloads/
+ report.pdf
+ project/
+ notes.txt
+
+ becomes:
+
+ Downloads/
+ 2026-08-25/
+ pdf/
+ report.pdf
+ txt/
+ notes.txt
+ """
+ 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(
+ root=root,
+ source=source,
+ result=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 directories created by the organizer from
+ being discovered and processed during the same 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(
+ root: Path,
+ 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,
+ root,
+ ):
+ result.skipped += 1
+ return
+
+ extension_name = get_extension_name(
+ source
+ )
+
+ modified_date = get_modified_date(
+ source
+ )
+
+ destination = (
+ root
+ / modified_date
+ / extension_name
+ / 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 folder name used for a file type.
+
+ Files without an extension are placed in the 'other' folder.
+ """
+ suffix = path.suffix.lower()
+
+ if not suffix:
+ return "other"
+
+ return suffix.lstrip(".")
+
+
+def get_modified_date(path: Path) -> str:
+ """Return the file's modified 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,
+ root: Path,
+) -> bool:
+ """
+ Return True when a file is already in the organizer's date/type layout.
+
+ Expected layout relative to the selected root:
+
+ YYYY-MM-DD/
+ extension/
+ filename
+
+ Example:
+
+ 2026-08-25/
+ pdf/
+ report.pdf
+ """
+ try:
+ relative_path = path.resolve().relative_to(
+ root.resolve()
+ )
+ except ValueError:
+ return False
+
+ parts = relative_path.parts
+
+ if len(parts) != 3:
+ return False
+
+ date_directory = parts[0]
+ extension_directory = parts[1]
+
+ try:
+ parsed_date = datetime.date.fromisoformat(
+ date_directory
+ )
+ except ValueError:
+ return False
+
+ if parsed_date.isoformat() != date_directory:
+ return False
+
+ expected_extension = get_extension_name(
+ path
+ )
+
+ return (
+ extension_directory.lower()
+ == expected_extension.lower()
+ )
\ No newline at end of file
diff --git a/documents_organizer/settings.py b/documents_organizer/settings.py
new file mode 100644
index 0000000..873ef48
--- /dev/null
+++ 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/__init__.py b/documents_organizer/ui/__init__.py
new file mode 100644
index 0000000..88cd438
--- /dev/null
+++ 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/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..7155c85
--- /dev/null
+++ b/documents_organizer/ui/components/folder_browser.py
@@ -0,0 +1,631 @@
+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._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
+ )
+
+ 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._handle_tree_open,
+ )
+
+ 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._root_item = None
+
+ self._item_paths.clear()
+ self._path_items.clear()
+ self._loaded_items.clear()
+
+ self._tree.delete(
+ *self._tree.get_children()
+ )
+
+ self._show_empty_state()
+
+ self._notify_selection_changed()
+
+ # -------------------------------------------------------------------------
+ # Tree rendering
+ # -------------------------------------------------------------------------
+
+ def _render(
+ self,
+ *,
+ root: Path,
+ preferred_path: Path | None,
+ ) -> None:
+ """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(
+ "",
+ "end",
+ text=str(root),
+ open=True,
+ )
+
+ self._root_item = root_item
+
+ self._register_item(
+ root_item,
+ root,
+ )
+
+ # Only the root's immediate children are loaded here.
+ self._load_children(
+ root_item
+ )
+
+ preferred_item = None
+
+ if preferred_path is not None:
+ preferred_item = (
+ self._reveal_path(
+ preferred_path
+ )
+ )
+
+ 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 _load_children(
+ self,
+ item: str,
+ ) -> None:
+ """
+ Load one level of child directories beneath a Treeview item.
+
+ 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:
+ directory_items = sorted(
+ directory.iterdir(),
+ key=lambda path: (
+ path.name.lower()
+ ),
+ )
+
+ except (
+ PermissionError,
+ FileNotFoundError,
+ OSError,
+ ):
+ return
+
+ for item_path in directory_items:
+ try:
+ 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
+
+ self._insert_directory(
+ parent=item,
+ directory=resolved_path,
+ )
+
+ def _insert_directory(
+ self,
+ *,
+ parent: str,
+ directory: Path,
+ ) -> str:
+ """
+ Insert a directory without loading its children.
+
+ 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
+ )
+ )
+
+ 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 current_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/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/components/menu_bar.py b/documents_organizer/ui/components/menu_bar.py
new file mode 100644
index 0000000..fb6607d
--- /dev/null
+++ 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/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/dialogs.py b/documents_organizer/ui/dialogs.py
new file mode 100644
index 0000000..7139782
--- /dev/null
+++ 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
new file mode 100644
index 0000000..97163e6
--- /dev/null
+++ b/documents_organizer/ui/main_window.py
@@ -0,0 +1,885 @@
+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.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
+from documents_organizer.services.organizer import OrganizationResult
+from documents_organizer.settings import (
+ APP_NAME,
+ DEFAULT_WINDOW_HEIGHT,
+ DEFAULT_WINDOW_WIDTH,
+ MIN_WINDOW_HEIGHT,
+ MIN_WINDOW_WIDTH,
+ 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.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.components.toolbar import Toolbar
+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:
+ """Main Documents Organizer application window."""
+
+ def __init__(self, root: tk.Tk) -> None:
+ self.root = root
+
+ self.folder_path: Path | None = None
+ self.is_closing = False
+
+ 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,
+ )
+
+ self._configure_window()
+
+ configure_styles(
+ self.root
+ )
+
+ self._create_menu_bar()
+ 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()
+
+ self.root.after(
+ UI_QUEUE_POLL_INTERVAL_MS,
+ self._process_ui_queue,
+ )
+
+ # -------------------------------------------------------------------------
+ # Window setup
+ # -------------------------------------------------------------------------
+
+ def _configure_window(self) -> None:
+ """Configure the root application window."""
+ self.root.title(
+ APP_NAME
+ )
+
+ self.root.geometry(
+ f"{DEFAULT_WINDOW_WIDTH}x"
+ f"{DEFAULT_WINDOW_HEIGHT}"
+ )
+
+ self.root.minsize(
+ MIN_WINDOW_WIDTH,
+ MIN_WINDOW_HEIGHT,
+ )
+
+ try:
+ self.root.iconbitmap(
+ str(
+ get_image_path(
+ WINDOW_ICON_FILE
+ )
+ )
+ )
+
+ except (
+ tk.TclError,
+ OSError,
+ ):
+ pass
+
+ def _bind_events(self) -> None:
+ """Bind application-level events."""
+ self.root.protocol(
+ "WM_DELETE_WINDOW",
+ self.exit_app,
+ )
+
+ # -------------------------------------------------------------------------
+ # Menu bar
+ # -------------------------------------------------------------------------
+
+ def _create_menu_bar(self) -> None:
+ """Create the application menu bar."""
+ 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,
+ )
+
+ # -------------------------------------------------------------------------
+ # Main layout
+ # -------------------------------------------------------------------------
+
+ 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,
+ 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."""
+ self.header = Header(
+ self.main_frame
+ )
+
+ self.header.grid(
+ row=0,
+ column=0,
+ sticky="ew",
+ pady=(
+ 0,
+ 12,
+ ),
+ )
+
+ def _create_toolbar(self) -> None:
+ """Create the main action toolbar."""
+ 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,
+ )
+
+ self.toolbar.grid(
+ row=1,
+ column=0,
+ sticky="ew",
+ pady=(
+ 0,
+ 12,
+ ),
+ )
+
+ def _create_folder_summary(self) -> None:
+ """Create the selected-location summary."""
+ self.folder_summary = FolderSummary(
+ self.main_frame
+ )
+
+ self.folder_summary.grid(
+ row=2,
+ column=0,
+ sticky="ew",
+ pady=(
+ 0,
+ 12,
+ ),
+ )
+
+ 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
+ ),
+ on_open_selected=(
+ self._open_folder_path
+ ),
+ )
+
+ self.paned_window.add(
+ self.folder_browser,
+ weight=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.grid(
+ row=3,
+ column=0,
+ sticky="nsew",
+ )
+
+ self._create_folder_browser()
+ self._create_log_panel()
+
+ def _create_log_panel(self) -> None:
+ """Create the activity log panel."""
+ self.activity_log = ActivityLog(
+ self.paned_window
+ )
+
+ self.paned_window.add(
+ self.activity_log,
+ weight=3,
+ )
+
+ def _create_status_bar(self) -> None:
+ """Create the bottom application status area."""
+ separator = ttk.Separator(
+ self.main_frame,
+ orient=tk.HORIZONTAL,
+ )
+
+ separator.grid(
+ row=4,
+ column=0,
+ sticky="ew",
+ pady=(
+ 12,
+ 6,
+ ),
+ )
+
+ self.status_bar = StatusBar(
+ self.main_frame
+ )
+
+ self.status_bar.grid(
+ row=5,
+ column=0,
+ sticky="ew",
+ )
+
+ # -------------------------------------------------------------------------
+ # Folder selection and browser
+ # -------------------------------------------------------------------------
+
+ def select_folder(self) -> None:
+ """Allow the user to select a root directory."""
+ if self.operations.is_busy:
+ show_warning(
+ self.root,
+ "Operation in Progress",
+ (
+ "Please wait for the current "
+ "file operation to finish."
+ ),
+ )
+ return
+
+ selected_folder = filedialog.askdirectory(title="Select Folder")
+
+ if not selected_folder:
+ return
+
+ folder = Path(
+ selected_folder
+ ).resolve()
+
+ try:
+ self.folder_browser.load(
+ folder
+ )
+
+ except (
+ FileNotFoundError,
+ NotADirectoryError,
+ PermissionError,
+ OSError,
+ ) as exc:
+ show_error(
+ self.root,
+ "Unable to Open Folder",
+ str(exc),
+ )
+ return
+
+ self.folder_path = folder
+
+ self.folder_summary.set_root(
+ folder
+ )
+
+ self.log_to_text(f"Selected folder: {folder}")
+
+ self.set_status("Folder selected.")
+
+ self._update_action_states()
+
+ def refresh_treeview(self) -> None:
+ """Refresh the displayed folder tree."""
+ if self.folder_path is None:
+ return
+
+ try:
+ self.folder_browser.refresh()
+
+ except (
+ FileNotFoundError,
+ NotADirectoryError,
+ PermissionError,
+ OSError,
+ ) as exc:
+ show_error(
+ self.root,
+ "Folder Unavailable",
+ str(exc),
+ )
+ return
+
+ self.set_status("Folder tree refreshed.")
+
+ def _on_folder_selection_changed(
+ self,
+ selected_folder: Path | None,
+ ) -> None:
+ """Handle changes to the folder browser selection."""
+ if selected_folder is None:
+ self.folder_summary.clear_target()
+
+ else:
+ self.folder_summary.set_target(
+ selected_folder
+ )
+
+ self._update_action_states()
+
+ # -------------------------------------------------------------------------
+ # Organizer
+ # -------------------------------------------------------------------------
+
+ def run_organizer(self) -> None:
+ """Start an organize operation for the selected folder."""
+ selected_folder = self.folder_browser.selected_path
+
+ if selected_folder is None:
+ show_error(
+ self.root,
+ "No Folder Selected",
+ "Please select a folder first.",
+ )
+ return
+
+ if not selected_folder.is_dir():
+ show_error(
+ self.root,
+ "Invalid Folder",
+ (
+ "The selected folder does "
+ "not exist."
+ ),
+ )
+ return
+
+ if not self.operations.organize(
+ selected_folder
+ ):
+ self._show_operation_in_progress_warning()
+
+ def _handle_organization_result(
+ self,
+ result: OrganizationResult,
+ ) -> None:
+ """Display organizer results."""
+ self._write_completed_operation_presentation(
+ present_organization_result(
+ result
+ )
+ )
+
+ def _handle_organization_error(
+ self,
+ message: str,
+ ) -> None:
+ """Display a fatal organizer error."""
+ self._write_operation_presentation(
+ present_organization_error(
+ message
+ )
+ )
+
+ show_error(
+ self.root,
+ "Organization Failed",
+ message,
+ )
+
+ # -------------------------------------------------------------------------
+ # Flattener
+ # -------------------------------------------------------------------------
+
+ def run_flattener(self) -> None:
+ """Start a flatten operation for the selected folder."""
+ selected_folder = self.folder_browser.selected_path
+
+ if selected_folder is None:
+ show_error(
+ self.root,
+ "No Folder Selected",
+ "Please select a folder first.",
+ )
+ return
+
+ if not selected_folder.is_dir():
+ show_error(
+ self.root,
+ "Invalid Folder",
+ (
+ "The selected folder does "
+ "not exist."
+ ),
+ )
+ return
+
+ if not self.operations.flatten(
+ selected_folder
+ ):
+ self._show_operation_in_progress_warning()
+
+ def _handle_flatten_result(
+ self,
+ result: FlattenResult,
+ ) -> None:
+ """Display flattener results."""
+ self._write_completed_operation_presentation(
+ present_flatten_result(
+ result
+ )
+ )
+
+ def _handle_flatten_error(
+ self,
+ message: str,
+ ) -> None:
+ """Display a fatal flattener error."""
+ self._write_operation_presentation(
+ present_flatten_error(
+ message
+ )
+ )
+
+ show_error(
+ self.root,
+ "Flattening Failed",
+ message,
+ )
+
+ def stop_flattening(self) -> None:
+ """Request cancellation of an active flatten operation."""
+ self.operations.cancel_flatten()
+
+ # -------------------------------------------------------------------------
+ # Operation state
+ # -------------------------------------------------------------------------
+
+ 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()
+ )
+
+ selected_folder = self.folder_browser.selected_path
+
+ target_available = (
+ selected_folder is not None
+ and selected_folder.is_dir()
+ )
+
+ busy = self.operations.is_busy
+
+ select_enabled = (
+ not busy
+ )
+
+ operations_enabled = (
+ target_available
+ and not busy
+ )
+
+ utilities_enabled = (
+ target_available
+ and root_available
+ and not busy
+ )
+
+ self.toolbar.set_states(
+ select_enabled=select_enabled,
+ operations_enabled=operations_enabled,
+ 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=(
+ 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."
+ ),
+ )
+
+ # -------------------------------------------------------------------------
+ # Operation presentation
+ # -------------------------------------------------------------------------
+
+ 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
+ # -------------------------------------------------------------------------
+
+ def _process_ui_queue(self) -> None:
+ """
+ Process application-shell messages.
+
+ Only the Tkinter main thread updates widgets.
+ """
+ try:
+ while True:
+ event_name, payload = (
+ self.ui_queue.get_nowait()
+ )
+
+ if event_name == "show_window":
+ self.root.deiconify()
+ self.root.lift()
+ self.root.focus_force()
+
+ elif event_name == "exit_application":
+ self.root.deiconify()
+ self.root.lift()
+
+ self.exit_app()
+
+ except queue.Empty:
+ pass
+
+ 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,
+ ) -> None:
+ """Update the application status message."""
+ self.status_bar.set_status(
+ message
+ )
+
+ def _log_startup_message(self) -> None:
+ """Display initial application information."""
+ self.log_to_text(
+ f"{APP_NAME} v{__version__} started."
+ )
+
+ self.log_to_text(
+ "Select a folder to begin."
+ )
+
+ def log_to_text(
+ self,
+ message: str,
+ ) -> None:
+ """Write a message to the activity log."""
+ self.activity_log.write(
+ message
+ )
+
+ def clear_log(self) -> None:
+ """Clear the activity log."""
+ self.activity_log.clear()
+
+ self.activity_log.write(
+ "Activity log cleared."
+ )
+
+ # -------------------------------------------------------------------------
+ # File manager
+ # -------------------------------------------------------------------------
+
+ def open_selected_folder(self) -> None:
+ """Open the selected folder in the platform file manager."""
+ selected_folder = self.folder_browser.selected_path
+
+ if selected_folder is None:
+ show_error(
+ self.root,
+ "No Folder Selected",
+ "Please select a folder first.",
+ )
+ 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(
+ folder
+ )
+
+ except (
+ FileNotFoundError,
+ NotADirectoryError,
+ OSError,
+ ) as exc:
+ show_error(
+ self.root,
+ "Unable to Open Folder",
+ str(exc),
+ )
+
+ # -------------------------------------------------------------------------
+ # System tray
+ # -------------------------------------------------------------------------
+
+ def hide_window(self) -> None:
+ """Hide the application in the system tray."""
+ if self.tray_manager.is_running:
+ self.root.withdraw()
+ return
+
+ try:
+ self.tray_manager.start()
+
+ except Exception as exc:
+ show_error(
+ self.root,
+ "System Tray Error",
+ (
+ f"{APP_NAME} could not create "
+ "the system tray icon.\n\n"
+ f"{exc}"
+ ),
+ )
+ return
+
+ self.root.withdraw()
+
+ def _request_show_window(self) -> None:
+ """Queue a request to restore the application window."""
+ self.ui_queue.put(
+ (
+ "show_window",
+ None,
+ )
+ )
+
+ def _request_exit_application(self) -> None:
+ """Queue a request to exit the application."""
+ self.ui_queue.put(
+ (
+ "exit_application",
+ None,
+ )
+ )
+
+ # -------------------------------------------------------------------------
+ # Application commands
+ # -------------------------------------------------------------------------
+
+ def show_about(self) -> None:
+ """Display application information."""
+ show_about_dialog(
+ self.root
+ )
+
+ def exit_app(self) -> None:
+ """Close the application safely."""
+ if self.operations.is_busy:
+ show_warning(
+ self.root,
+ "Operation in Progress",
+ (
+ "Files are currently being "
+ "processed.\n\n"
+ "Please allow the operation "
+ "to finish, or cancel the "
+ "flatten operation before "
+ f"exiting {APP_NAME}."
+ ),
+ )
+ return
+
+ 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/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
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
diff --git a/main.py b/main.py
index 9103b5f..fb27eeb 100644
--- a/main.py
+++ b/main.py
@@ -1,366 +1,5 @@
-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 documents_organizer.app import run
-# Define a global flag for canceling flattening operation
-cancel_flattening = False
-# Global variable to store the folder path
-folder_path = ""
-
-# Global dictionary to track files organized by extension
-organized_files = {}
-
-# 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.")
- return
-
- folder_path = get_full_path(tree, selected_item)
- if not folder_path:
- 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()
-
-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.")
-
-def flatten_subfolders(folder):
- """Flatten subfolders of the specified folder."""
- 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)
-
- # 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."""
- 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
-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)
-
-# Function to add extensions to flatten
-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 = 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 []
-
-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."""
- icon.stop()
- win.destroy()
-
-# Function to hide the window
-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."""
- icon.stop()
- win.after(0, win.deiconify())
-
-# Function to handle "Select Folder" menu option
-def select_folder():
- """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
-def run_organizer():
- """Handle the 'Run' menu option."""
- selected_item = tree.focus()
- if not selected_item:
- messagebox.showerror("Error", "Please select a folder first.")
- return
-
- global folder_path
- folder_path = tree.item(selected_item)['text']
- organize_files(folder_path)
-
-# Function to handle "Exit" menu option
-def exit_app():
- """Handle the 'Exit' menu option."""
- root.quit()
-
-# 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)
-
-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."""
- 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)
-
-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)
- if os.path.isdir(item_path):
- node = tree.insert(parent, 'end', text=item)
- 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
-def scroll_to_end():
- """Scroll to the end of the log."""
- 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))
-
-# 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)
- 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)
- start_application()
-
-def open_explorer_folder():
- selected_item = tree.selection()[0]
- folder_path = get_full_path(tree, selected_item)
- os.startfile(folder_path)
-
-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")
-
-
-# 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
-
-# Create an instance of tkinter frame or window
-win = tk.Tk()
-
-win.title("Documents Organizer")
-win.iconbitmap("images/folder-256.ico")
-# Set the size of the window
-win.geometry("1080x800")
-
-# Create menu bar
-menu_bar = tk.Menu(win)
-win.config(menu=menu_bar)
-
-# 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)
-
-# 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)
-
-# Extensions submenu
-action_menu.add_command(label="Add Extensions", command=add_extensions)
-
-# 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)
-
-menu_bar.add_cascade(label="Action", menu=action_menu)
-
-# 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)
-
-# 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)
-
-# Bind the right-click event to the popup_menu function
-tree.bind("", popup_menu)
-
-# 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)
-
-log_text = scrolledtext.ScrolledText(log_frame, height=10, width=50)
-log_text.pack(expand=tk.YES, fill=tk.BOTH)
-
-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
diff --git a/packaging/windows/version_info.txt b/packaging/windows/version_info.txt
new file mode 100644
index 0000000..0e92261
--- /dev/null
+++ b/packaging/windows/version_info.txt
@@ -0,0 +1,62 @@
+VSVersionInfo(
+ ffi=FixedFileInfo(
+ filevers=(0, 2, 0, 1),
+ prodvers=(0, 2, 0, 1),
+ 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.1",
+ ),
+ StringStruct(
+ "InternalName",
+ "DocumentsOrganizer",
+ ),
+ StringStruct(
+ "LegalCopyright",
+ "Copyright © 2026 David O. Southwood",
+ ),
+ StringStruct(
+ "OriginalFilename",
+ "DocumentsOrganizer.exe",
+ ),
+ StringStruct(
+ "ProductName",
+ "Documents Organizer",
+ ),
+ StringStruct(
+ "ProductVersion",
+ "0.2.0rc1",
+ ),
+ ],
+ )
+ ]
+ ),
+ VarFileInfo(
+ [
+ VarStruct(
+ "Translation",
+ [
+ 1033,
+ 1200,
+ ],
+ )
+ ]
+ ),
+ ],
+)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..28021f8
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,26 @@
+[build-system]
+requires = ["setuptools>=75"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "documents-organizer"
+version = "0.2.0rc1"
+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",
+ "pyinstaller==6.22.2",
+]
+
+[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/scripts/build-windows.ps1 b/scripts/build-windows.ps1
new file mode 100644
index 0000000..b44ce88
--- /dev/null
+++ b/scripts/build-windows.ps1
@@ -0,0 +1,782 @@
+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
+}
+
+
+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
+# -----------------------------------------------------------------------------
+
+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"
+
+
+ # -------------------------------------------------------------------------
+ # Application version
+ # -------------------------------------------------------------------------
+
+ 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"
+
+ 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 ProductVersion"
+
+ $WindowsFileVersion = Get-RegexValue `
+ -Path $VersionInfoPath `
+ -Pattern (
+ '(?s)StringStruct\(' +
+ '\s*"FileVersion",' +
+ '\s*"([^"]+)"'
+ ) `
+ -Description "Windows FileVersion"
+
+ $WindowsFixedFileVersion = Get-WindowsFixedVersion `
+ -Path $VersionInfoPath `
+ -FieldName "filevers"
+
+ $WindowsFixedProductVersion = Get-WindowsFixedVersion `
+ -Path $VersionInfoPath `
+ -FieldName "prodvers"
+
+ 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 (
+ $WindowsProductVersion -ne
+ $Version
+ ) {
+ throw (
+ "Windows ProductVersion does not match " +
+ "the application version.`n" +
+ "Expected: $Version`n" +
+ "Actual: $WindowsProductVersion"
+ )
+ }
+
+ if (
+ $WindowsFileVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ throw (
+ "Windows FileVersion string does not match " +
+ "the expected numeric Windows version.`n" +
+ "Expected: $ExpectedWindowsVersion`n" +
+ "Actual: $WindowsFileVersion"
+ )
+ }
+
+ if (
+ $WindowsFixedFileVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ throw (
+ "FixedFileInfo filevers does not match " +
+ "the expected Windows version.`n" +
+ "Expected: $ExpectedWindowsVersion`n" +
+ "Actual: $WindowsFixedFileVersion"
+ )
+ }
+
+ if (
+ $WindowsFixedProductVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ 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
+ # -------------------------------------------------------------------------
+
+ 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$ReleaseVersion"
+
+ 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
+
+ $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.`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)"
+ )
+ }
+
+ if (
+ $ExecutableVersion.ProductVersion -ne
+ $Version
+ ) {
+ throw (
+ "Executable ProductVersion string does not match " +
+ "the application version.`n" +
+ "Expected: $Version`n" +
+ "Actual: $($ExecutableVersion.ProductVersion)"
+ )
+ }
+
+ if (
+ $ExecutableVersion.FileVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ throw (
+ "Executable FileVersion string does not match " +
+ "the expected Windows version.`n" +
+ "Expected: $ExpectedWindowsVersion`n" +
+ "Actual: $($ExecutableVersion.FileVersion)"
+ )
+ }
+
+ if (
+ $ExecutableFixedFileVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ throw (
+ "Executable fixed FileVersion does not match " +
+ "the expected Windows version.`n" +
+ "Expected: $ExpectedWindowsVersion`n" +
+ "Actual: $ExecutableFixedFileVersion"
+ )
+ }
+
+ if (
+ $ExecutableFixedProductVersion -ne
+ $ExpectedWindowsVersion
+ ) {
+ throw (
+ "Executable fixed ProductVersion does not match " +
+ "the expected Windows version.`n" +
+ "Expected: $ExpectedWindowsVersion`n" +
+ "Actual: $ExecutableFixedProductVersion"
+ )
+ }
+
+ Write-Host ""
+ Write-Host "Executable metadata is valid."
+
+
+ # -------------------------------------------------------------------------
+ # Release artifact
+ # -------------------------------------------------------------------------
+
+ Write-Step "Creating release artifact"
+
+ if (-not (Test-Path $ArtifactsDirectory)) {
+ New-Item `
+ -ItemType Directory `
+ -Path $ArtifactsDirectory `
+ | Out-Null
+ }
+
+ $ArtifactName = (
+ "DocumentsOrganizer-v" +
+ $ReleaseVersion +
+ "-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 "Application version:"
+ Write-Host " $Version"
+
+ Write-Host ""
+ 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"
+
+ 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$ReleaseVersion " +
+ "Windows build completed successfully."
+ )
+}
+finally {
+ Pop-Location
+}
\ 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
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
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
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
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
diff --git a/tests/test_organizer.py b/tests/test_organizer.py
new file mode 100644
index 0000000..76806eb
--- /dev/null
+++ b/tests/test_organizer.py
@@ -0,0 +1,452 @@
+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_date_then_extension(
+ 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
+ / TEST_DATE
+ / "pdf"
+ / "report.pdf"
+ )
+
+ expected_image = (
+ tmp_path
+ / TEST_DATE
+ / "jpg"
+ / "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_nested_files_are_centralized_into_selected_root(
+ tmp_path: Path,
+):
+ project = tmp_path / "project"
+ project.mkdir()
+
+ source = project / "photo.jpg"
+ source.write_text("image")
+
+ set_test_modified_date(source)
+
+ result = organize_directory(
+ tmp_path
+ )
+
+ destination = (
+ 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(
+ tmp_path: Path,
+):
+ source = tmp_path / "README"
+ source.write_text("readme")
+
+ set_test_modified_date(source)
+
+ result = organize_directory(
+ tmp_path
+ )
+
+ destination = (
+ tmp_path
+ / TEST_DATE
+ / "other"
+ / "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_organizer_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
+ / TEST_DATE
+ / "pdf"
+ )
+
+ 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_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,
+):
+ 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
+ / TEST_DATE
+ / "pdf"
+ / "report.pdf"
+ )
+
+ nested_duplicate = (
+ tmp_path
+ / TEST_DATE
+ / "pdf"
+ / TEST_DATE
+ / "pdf"
+ / "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_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(
+ Path("README")
+ )
+ == "other"
+ )
+
+
+def test_is_already_organized_detects_date_type_layout(
+ tmp_path: Path,
+):
+ organized_directory = (
+ tmp_path
+ / TEST_DATE
+ / "pdf"
+ )
+
+ organized_directory.mkdir(
+ parents=True
+ )
+
+ file_path = (
+ organized_directory
+ / "report.pdf"
+ )
+
+ file_path.write_text("report")
+
+ assert is_already_organized(
+ file_path,
+ tmp_path,
+ )
+
+
+def test_is_already_organized_rejects_old_extension_date_layout(
+ tmp_path: Path,
+):
+ old_directory = (
+ tmp_path
+ / "pdf"
+ / TEST_DATE
+ )
+
+ old_directory.mkdir(
+ parents=True
+ )
+
+ file_path = (
+ old_directory
+ / "report.pdf"
+ )
+
+ file_path.write_text("report")
+
+ assert not is_already_organized(
+ file_path,
+ tmp_path,
+ )
+
+
+def test_is_already_organized_rejects_normal_nested_file(
+ tmp_path: Path,
+):
+ project = tmp_path / "project"
+ project.mkdir()
+
+ file_path = (
+ project
+ / "report.pdf"
+ )
+
+ file_path.write_text("report")
+
+ assert not is_already_organized(
+ 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
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