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 @@ ![Documents Organizer Logo](images/logo/folder-48.png) -A Python desktop utility for organizing directories by file type and modification date, flattening previously organized folder structures, and managing collections of files through a graphical interface. +A Python desktop utility for safely organizing files by modification date and file type, flattening organizer-generated folder structures, and managing file collections through a graphical interface. Documents Organizer was created to automate repetitive file-management tasks while still giving the user control over the folder being processed and visibility into what the application is doing. +> **Current version:** v0.2.0 +> **Current platform focus:** Windows + --- ## Features -- **Organize Files** — Organize files into folders based on file extension and modification date. -- **Flatten Folders** — Move files out of supported nested folder structures and back into their parent folders. -- **Custom Extensions** — Add additional file extensions to the flattening workflow. -- **Cancelable Operations** — Stop a flattening operation while it is in progress. -- **Folder Selection** — Select the directory that should be organized or flattened. -- **Explorer Integration** — Open selected folders directly in Windows File Explorer. -- **Activity Log** — Monitor file operations and application messages while an operation is running. +- **Organize Files:** Organize files into a date-first, file-type-based directory structure. +- **Flatten Folders:** Move files from organizer-generated date/type folders back into the selected root directory. +- **Nested Folder Support:** Recursively discover files inside nested directories and centralize them under the selected root. +- **Duplicate Protection:** Preserve files with duplicate names by automatically adding numbered suffixes instead of overwriting existing files. +- **Extensionless File Support:** Organize files without extensions into an `other` directory. +- **Cancelable Flattening:** Request cancellation of an active flatten operation. +- **Lazy-Loaded Folder Browser:** Load directory contents only as folders are expanded instead of recursively scanning the entire tree at startup. +- **Operation Target Selection:** Select a nested folder in the Folder Browser and use it as the target for file operations. +- **File Manager Integration:** Open selected folders directly in the platform file manager. +- **System Tray Support:** Explicitly minimize Documents Organizer to the system tray and restore or quit it from the tray menu. +- **Activity Log:** View timestamped operation messages, summaries, and errors. +- **Background File Operations:** Run file operations outside the Tkinter UI thread to keep the interface responsive. +- **Safe Directory Cleanup:** Remove only empty organizer-generated directories during flattening. +- **Automated Tests:** Filesystem, controller, presenter, folder browser, and complete workflow behavior are covered by automated tests. --- -## Screenshots +## What's New in v0.2.0 -### Main Application +v0.2.0 is a major rewrite of Documents Organizer. -Documents Organizer main application window +The release introduces a new organization structure, safer filesystem behavior, a redesigned interface, background operation management, lazy-loaded folder browsing, and a substantially refactored application architecture. -### Folder Selection +### New Organization Structure -Selecting a folder in Documents Organizer +Previous versions organized files using an extension-first structure. -### Adding Extensions +v0.2.0 uses a **date-first structure**: -Adding file extensions in Documents Organizer +```text +Selected Folder/ +├── YYYY-MM-DD/ +│ ├── pdf/ +│ ├── jpg/ +│ ├── txt/ +│ ├── zip/ +│ └── other/ +└── YYYY-MM-DD/ + └── ... +``` -### Flattening Folders +For example: -Flattening folders in Documents Organizer +```text +Documents/ +├── 2026-08-25/ +│ ├── pdf/ +│ │ └── report.pdf +│ ├── jpg/ +│ │ └── photo.jpg +│ └── txt/ +│ └── notes.txt +└── 2026-08-26/ + ├── zip/ + │ └── archive.zip + └── other/ + └── README +``` -### Organizing Files +The date is currently determined using each file's **modified date**. -Organizing files in Documents Organizer +Files without an extension are placed in: + +```text +other/ +``` + +--- + +## Screenshots + +The user interface was redesigned for v0.2.0. + +Updated screenshots will be added after the v0.2.0 Windows package has completed final release testing. + +The previous screenshots represented the older application interface and workflows and are no longer included here because they do not accurately represent v0.2.0. --- @@ -50,31 +98,78 @@ Documents Organizer was created to automate repetitive file-management tasks whi Documents Organizer is written in Python and uses Tkinter for its graphical interface. -Before running the application, make sure you have: +Development currently targets: + +```text +Python >= 3.12 +``` + +Runtime dependencies include: + +```text +pillow==10.2.0 +pystray==0.19.5 +``` + +Development dependencies include: + +```text +pytest>=8,<10 +``` -- Python installed -- The dependencies listed in `requirements.txt` -- Permission to read and modify the directories you intend to organize +You also need permission to read, move, and modify files within the directories you intend to process. + +--- ### Clone the Repository -Clone the repository from: +Clone the repository: + +```powershell +git clone https://github.com/DOS1986/Documents-Organizer.git +``` + +Enter the project directory: -`https://github.com/DOS1986/Documents-Organizer` +```powershell +cd Documents-Organizer +``` -Then navigate into the cloned project directory. +--- + +### Create a Virtual Environment -### Install Dependencies +On Windows: -Install the Python packages listed in: +```powershell +python -m venv .venv +``` -`requirements.txt` +Activate it: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +--- + +### Install the Project + +Install Documents Organizer in editable mode with development dependencies: + +```powershell +python -m pip install -e ".[dev]" +``` + +--- ### Run the Application -Run the application's main Python entry point. +From the project root: -The exact setup and execution commands can be added here once the current project structure has been reviewed and verified. +```powershell +python main.py +``` --- @@ -82,92 +177,728 @@ The exact setup and execution commands can be added here once the current projec ### Select a Folder -Choose the folder you want Documents Organizer to work with. +Choose **Select Folder** from the toolbar or File menu. -Always verify that the correct folder has been selected before starting an operation. +The selected directory becomes the application's **Root Folder**. -### Organize Files +The Folder Browser displays that directory and its immediate subdirectories. -Use the organize operation to sort files within the selected directory into folders based on file extension and modification date. +Always verify that the correct folder has been selected before beginning a filesystem operation. -### Flatten Folders +--- -Use the flatten operation to move files out of supported nested folder structures and back into their parent folders. +### Root Folder vs. Operation Target -### Add Extensions +Documents Organizer distinguishes between two locations: -Additional file extensions can be added through the application when files outside the default extension list need to be included in the flattening process. +- **Root Folder:** The folder originally selected using **Select Folder**. +- **Operation Target:** The folder currently selected in the Folder Browser. -### Cancel an Operation +When the root is initially loaded, both values point to the same directory. -A flattening operation can be canceled while it is running using the application's cancellation option. +Selecting a nested folder changes the Operation Target. -### Reveal in Explorer +Commands such as **Organize**, **Flatten**, and **Open Selected** operate on the current Operation Target. -Right-click a folder in the tree view to open that location directly in Windows File Explorer. +--- + +## Organizing Files + +Select the folder you want to organize and choose **Organize**. + +Documents Organizer recursively discovers files beneath the Operation Target and centralizes them into: + +```text +// +``` + +For example, starting with: + +```text +MyFiles/ +├── report.pdf +├── photo.jpg +└── project/ + └── notes.txt +``` + +Organize may produce: -### Monitor Operations +```text +MyFiles/ +├── project/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + ├── jpg/ + │ └── photo.jpg + └── txt/ + └── notes.txt +``` -Use the application's log to monitor progress, confirmation messages, and errors while file operations are being performed. +The original: + +```text +project/ +``` + +directory remains. + +v0.2.0 intentionally does **not** remove original source directories after their files have been centralized. --- -## Running a Packaged Release +## Nested Folder Centralization -Packaged Windows releases may be made available through the project's GitHub Releases page: +Files found in nested directories are organized into the selected Operation Target's date/type structure. -[Documents Organizer Releases](https://github.com/DOS1986/Documents-Organizer/releases) +For example: + +```text +Downloads/ +├── first/ +│ └── report.pdf +└── second/ + └── photo.jpg +``` + +may become: + +```text +Downloads/ +├── first/ +├── second/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + └── jpg/ + └── photo.jpg +``` + +The original nested hierarchy is not recreated inside the date directories. + +This is intentional. + +Documents Organizer is designed to **centralize files**, not preserve their original nested folder locations. + +--- + +## Duplicate Filename Protection + +Documents Organizer does not intentionally overwrite an existing destination file. + +When multiple files have the same name, numbered suffixes are automatically added. + +For example: + +```text +first/report.pdf +second/report.pdf +third/report.pdf +``` + +may become: + +```text +2026-08-26/ +└── pdf/ + ├── report.pdf + ├── report (1).pdf + └── report (2).pdf +``` + +The same collision protection is used when files are flattened back into the root directory. + +--- + +## Files Without Extensions + +Files without an extension are supported. + +For example: + +```text +README +LICENSE +Dockerfile +``` + +are organized beneath: + +```text +other/ +``` + +Example: + +```text +2026-08-26/ +└── other/ + ├── README + └── LICENSE +``` + +--- + +## Flattening Files + +**Flatten** reverses the organizer-generated date/type structure by moving eligible files back into the selected Operation Target. + +For example: + +```text +Documents/ +└── 2026-08-26/ + ├── pdf/ + │ └── report.pdf + ├── jpg/ + │ └── photo.jpg + └── txt/ + └── notes.txt +``` + +becomes: + +```text +Documents/ +├── report.pdf +├── photo.jpg +└── notes.txt +``` + +After files are moved, Documents Organizer attempts to remove empty organizer-generated type and date directories. + +Directory cleanup is intentionally conservative. + +Documents Organizer uses empty-directory removal rather than recursive deletion. If unexpected content remains inside a directory, that directory is preserved. + +--- + +## Flatten Is Not Undo + +Flatten reverses the structure created by Documents Organizer, but it is **not a full undo system**. + +For example, suppose the original files were: + +```text +Documents/ +├── work/ +│ └── report.pdf +└── personal/ + └── photo.jpg +``` + +After organizing and then flattening, the result is: + +```text +Documents/ +├── work/ +├── personal/ +├── report.pdf +└── photo.jpg +``` + +Documents Organizer currently does not record enough information to know that: + +```text +report.pdf +``` + +originally belonged inside: + +```text +work/ +``` + +A future release may add an operation manifest and full undo support capable of restoring original file locations. + +--- + +## Canceling Flatten + +An active flatten operation can be canceled using the **Cancel** button or corresponding menu command. + +Cancellation is cooperative. + +When cancellation is requested, the application signals the active flatten operation and stops processing at a safe point. + +Files already moved before the cancellation request are not automatically moved back. + +--- + +## Folder Browser + +v0.2.0 introduces a lazy-loaded Folder Browser. + +When a root folder is selected, Documents Organizer loads only its immediate subdirectories. + +For example: + +```text +LargeFolder/ +├── Games/ +├── Photos/ +├── Projects/ +└── Work/ +``` + +Documents Organizer does not immediately scan every directory beneath those folders. + +Instead: + +```text +Select Folder + ↓ +Load immediate directories + ↓ +Display Folder Browser + ↓ +User expands Projects + ↓ +Load Projects children +``` + +This prevents the interface from recursively walking potentially very large directory structures merely to display the browser. + +Nested folders are loaded as they are expanded. + +The Folder Browser also attempts to preserve the current nested selection when the tree is refreshed without loading unrelated branches. + +--- + +## Open in File Manager + +The selected Operation Target can be opened directly from Documents Organizer. + +Use: -If no packaged release is currently available, the application can be run directly from the Python source. +```text +Open Selected +``` + +or right-click a folder in the Folder Browser and choose: + +```text +Open in File Manager +``` + +On Windows, the folder opens in File Explorer. + +The underlying file-manager integration is written with cross-platform support in mind. --- -## Configuration +## Activity Log + +Documents Organizer includes a timestamped Activity Log. + +Example: + +```text +[14:32:18] Documents Organizer v0.2.0 started. +[14:32:20] Selected folder: C:\Documents +[14:32:23] Organizing: C:\Documents +[14:32:24] Organized 4 pdf files. +[14:32:24] Organized 2 jpg files. +[14:32:24] Organization complete. 6 files moved. +``` + +The log reports: -Documents Organizer does not require external configuration for normal use. +- application startup +- folder selection +- operation start +- organization summaries +- flatten summaries +- skipped files +- operation failures +- cancellation requests +- operation completion -Additional file extensions used by the flattening process can be added through the application's interface. +Use **Clear Log** to reset the current Activity Log. --- -## Technology +## System Tray -Documents Organizer currently uses: +Documents Organizer can be explicitly minimized to the system tray using: -- Python -- Tkinter -- Local filesystem operations -- Windows File Explorer integration +```text +File → Minimize to Tray +``` + +The tray menu provides: + +```text +Show +Quit +``` + +**Show** restores the application window. + +**Quit** closes Documents Organizer. + +Clicking the normal Windows close button exits the application rather than silently minimizing it to the tray. --- ## File Safety -Documents Organizer performs operations that can move files and change directory structures. +Documents Organizer performs real filesystem operations that move files and modify directory structures. + +v0.2.0 contains several protections intended to make these operations safer: + +- Existing destination files are not intentionally overwritten. +- Duplicate filenames receive numbered suffixes. +- Files without extensions are supported. +- Common operating-system metadata files are ignored. +- Organizer-generated files are detected to prevent repeated reorganization. +- Flattening only processes supported organizer-generated date/type structures. +- Directory cleanup removes only directories that are actually empty. +- Unexpected contents prevent directories from being removed. +- Original source directories are preserved. +- Concurrent organize and flatten operations are prevented. +- Filesystem operations run outside the Tkinter UI thread. +- Flatten operations support cancellation. +- Automated workflow tests verify organize-to-flatten round trips. + +Ignored system metadata files currently include: + +```text +.DS_Store +Thumbs.db +``` + +### Important + +No filesystem utility can eliminate every possible risk. + +Before using Documents Organizer on important files: + +1. Keep an appropriate backup. +2. Test the application on a disposable or sample directory first. +3. Verify the selected Root Folder and Operation Target. +4. Avoid manually changing the same files while an operation is running. +5. Review the Activity Log after an operation completes. + +--- + +## Repeated Operations + +Documents Organizer is designed to make repeated operations safe. + +Running **Organize** again against an already organized directory should not create an increasingly nested organization structure. + +Running **Flatten** against a directory that has already been flattened should complete without moving unrelated root files. + +These behaviors are covered by automated workflow tests. + +--- + +## Project Structure + +v0.2.0 separates filesystem operations, application coordination, presentation logic, and user-interface components. + +```text +Documents-Organizer/ +├── documents_organizer/ +│ ├── __init__.py +│ ├── app.py +│ ├── filesystem.py +│ ├── platform_utils.py +│ ├── resources.py +│ ├── settings.py +│ │ +│ ├── controllers/ +│ │ ├── __init__.py +│ │ └── operation_controller.py +│ │ +│ ├── presenters/ +│ │ ├── __init__.py +│ │ └── operation_presenter.py +│ │ +│ ├── services/ +│ │ ├── __init__.py +│ │ ├── flattener.py +│ │ └── organizer.py +│ │ +│ └── ui/ +│ ├── __init__.py +│ ├── dialogs.py +│ ├── main_window.py +│ ├── styles.py +│ ├── tray_manager.py +│ │ +│ └── components/ +│ ├── __init__.py +│ ├── activity_log.py +│ ├── folder_browser.py +│ ├── folder_summary.py +│ ├── header.py +│ ├── menu_bar.py +│ ├── status_bar.py +│ └── toolbar.py +│ +├── images/ +├── tests/ +├── main.py +├── pyproject.toml +├── requirements.txt +├── README.md +└── LICENSE +``` + +--- + +## Architecture + +### Services + +Filesystem operations are implemented under: + +```text +documents_organizer/services/ +``` + +The main services are: + +```text +organizer.py +flattener.py +``` + +They perform filesystem operations without depending on Tkinter. + +--- + +### Operation Controller + +Background operation coordination lives in: + +```text +documents_organizer/controllers/operation_controller.py +``` + +The Operation Controller manages: + +- organizer worker threads +- flattener worker threads +- operation lifecycle +- busy state +- flatten cancellation +- worker result dispatch +- prevention of concurrent filesystem operations +- clean controller shutdown + +--- + +### Presenters + +Operation result formatting lives in: + +```text +documents_organizer/presenters/operation_presenter.py +``` + +The presenter converts organizer and flattener results into user-facing log and status messages. + +It does not manipulate Tkinter widgets directly. + +--- + +### UI Components + +Reusable interface components live under: + +```text +documents_organizer/ui/components/ +``` -Before using the application on important data: +These include: -- Keep a backup of the files being organized. -- Test the application on a small sample directory first. -- Verify that the correct folder has been selected before starting an operation. -- Review the application log while operations are running. -- Avoid manually modifying the same files or folders while Documents Organizer is processing them. +```text +ActivityLog +FolderBrowser +FolderSummary +Header +MenuBar +StatusBar +Toolbar +``` -Use the application carefully when working with files that do not have another backup. +`MainWindow` coordinates these components rather than implementing their internal behavior. + +--- + +### System Tray + +System tray integration is isolated in: + +```text +documents_organizer/ui/tray_manager.py +``` + +This keeps `pystray` implementation details outside the main application window. + +--- + +### Styles + +Tkinter/ttk style configuration is centralized in: + +```text +documents_organizer/ui/styles.py +``` + +--- + +## Testing + +Documents Organizer includes automated tests covering both individual application components and complete filesystem workflows. + +Run the complete test suite: + +```powershell +python -m pytest +``` + +Run with verbose output: + +```powershell +python -m pytest -v +``` + +Compile-check the application: + +```powershell +python -m compileall main.py documents_organizer +``` + +A useful pre-commit check is: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest +``` + +--- + +## Test Coverage + +The v0.2.0 test suite covers areas including: + +- safe filesystem moves +- destination collision handling +- numbered duplicate filenames +- ignored system files +- modified-date organization +- extension/type organization +- extensionless files +- nested file centralization +- already-organized file detection +- flattening +- safe empty-directory cleanup +- flatten cancellation +- operation controller state +- concurrent operation prevention +- worker result dispatch +- operation presenter output +- resource path resolution +- lazy-loaded folder browsing +- on-demand directory expansion +- nested selection restoration +- deleted-selection fallback +- complete organize-to-flatten workflows +- duplicate preservation across round trips +- extensionless file preservation across round trips +- repeated organize safety +- repeated flatten safety + +Filesystem tests use temporary directories rather than modifying real user folders. + +--- + +## Running a Packaged Release + +Packaged Windows releases are planned to be made available through the project's GitHub Releases page: + +[Documents Organizer Releases](https://github.com/DOS1986/Documents-Organizer/releases) + +Until an official v0.2.0 executable is published, Documents Organizer can be run directly from source. + +--- + +## Packaging Status + +The v0.2.0 application code and test suite are being prepared for packaged Windows distribution. + +The current source entry point is: + +```powershell +python main.py +``` + +PyInstaller packaging and automated build workflows are planned as part of the v0.2.0 release process. + +Do not assume a prebuilt binary is available until it appears on the GitHub Releases page. + +--- + +## Current Platform Support + +v0.2.0 development and release testing are currently focused on: + +```text +Windows +``` + +Some application infrastructure is already implemented with cross-platform support in mind, including file-manager launching and packaged-resource handling. + +Linux and macOS packaged builds have not yet completed validation and should not currently be considered officially supported. --- ## Troubleshooting -If you encounter a problem while using Documents Organizer: +If Documents Organizer does not behave as expected: -- Review the application log for error messages. -- Confirm that Python and the required dependencies are installed. -- Verify that your user account has permission to read and modify the selected directory. -- Make sure files being processed are not locked by another application. -- Try reproducing the issue using a small test directory. +- Review the Activity Log for errors. +- Confirm that the required dependencies are installed. +- Verify that the selected directory still exists. +- Confirm that your user account has permission to read and modify the selected directory. +- Check whether files are locked by another application. +- Try reproducing the issue using a small disposable directory. +- Run the automated test suite if working from source. - Review existing GitHub issues to see whether the problem has already been reported. +For source installations, useful diagnostic commands include: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest -v +python main.py +``` + +--- + +## Roadmap + +Potential future improvements include: + +- Dry-run / preview mode before moving files +- Operation manifests +- Full undo support +- Original-location restoration +- Optional cleanup of original empty source directories +- Configurable organization strategies +- Configurable organization rules +- Additional file metadata options +- Expanded cross-platform testing +- Linux packaging +- macOS packaging +- Automated release builds +- Additional packaged release formats + --- ## Contributing @@ -178,8 +909,18 @@ If you would like to contribute: 1. Fork the repository. 2. Create a branch for your change. -3. Make and test your changes. -4. Submit a pull request describing what was changed and why. +3. Install the development dependencies. +4. Make your changes. +5. Run the complete test suite. +6. Confirm the application still launches correctly. +7. Submit a pull request describing what changed and why. + +Before submitting a pull request, run: + +```powershell +python -m compileall main.py documents_organizer +python -m pytest +``` --- @@ -195,9 +936,11 @@ When reporting a bug, please include: - What happened - What you expected to happen - Steps that reproduce the issue -- Any relevant application log output +- Your operating system +- Your Python version if running from source +- Any relevant Activity Log output -Please avoid including private file names, paths, or other sensitive information in public issue reports. +Please avoid including private file names, personal directory paths, or other sensitive information in public issue reports. --- @@ -211,7 +954,9 @@ Documents Organizer is licensed under the [MIT License](LICENSE). Documents Organizer is provided as-is without warranty. -The application performs filesystem operations that may move files and modify directory structures. Users are responsible for maintaining appropriate backups and verifying the selected directory before performing an operation. +The application performs filesystem operations that may move files and modify directory structures. + +Users are responsible for maintaining appropriate backups and verifying the selected directory before performing an operation. See the [MIT License](LICENSE) for the project's licensing terms. @@ -223,4 +968,4 @@ Created by [David O. Southwood](https://davidosouthwood.com). - [Website](https://davidosouthwood.com) - [GitHub](https://github.com/DOS1986) -- [LinkedIn](https://www.linkedin.com/in/davidsouthwood/) +- [LinkedIn](https://www.linkedin.com/in/davidsouthwood/) \ No newline at end of file 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