downsorter 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: downsorter
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Organize files by extension into category folders from the command line.
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: downloads,file sorting,organization,cli
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Downsorter
|
|
19
|
+
|
|
20
|
+
A small Python CLI tool that organizes files by extension into category folders.
|
|
21
|
+
|
|
22
|
+
Use it to clean up a messy folder, especially downloads, by moving images, documents, archives, installers, code, audio, and video files into the right subfolders.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- Organizes files by extension into category folders
|
|
27
|
+
- Preview mode so you can see changes before moving anything
|
|
28
|
+
- Safe move mode with `--apply` to actually perform the file moves
|
|
29
|
+
- Handles duplicate names by renaming files to `name (1).ext`, `name (2).ext`, etc.
|
|
30
|
+
- Ignores common system files like `desktop.ini` and `thumbs.db`
|
|
31
|
+
- Supports minimum file age filtering with `--min-age-days`
|
|
32
|
+
- Writes a CSV log of all moves for auditing
|
|
33
|
+
- Works on Windows, macOS, and Linux with Python 3.10+
|
|
34
|
+
|
|
35
|
+
## Platform Support
|
|
36
|
+
|
|
37
|
+
| Platform | Support | Notes |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| Windows 10/11 | Supported | Best tested platform for this project |
|
|
40
|
+
| macOS | Supported | Works with Python 3.10+ |
|
|
41
|
+
| Linux | Supported | Works with Python 3.10+ |
|
|
42
|
+
|
|
43
|
+
## Files
|
|
44
|
+
|
|
45
|
+
- `sorter.py` - main CLI script and package entry point
|
|
46
|
+
- `README.md` - project instructions
|
|
47
|
+
- `pyproject.toml` - package metadata for PyPI
|
|
48
|
+
- `LICENSE` - MIT license text
|
|
49
|
+
- `movelogs.csv` - example move log file written by the script
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
### From source
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### From PyPI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install downsorter
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
### Preview what will happen
|
|
68
|
+
|
|
69
|
+
This is the safest way to run the tool first.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
downsorter --folder "C:\Users\YourName\Downloads"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Actually move files
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
downsorter --folder "C:\Users\YourName\Downloads" --apply
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Only move files older than 7 days
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
downsorter --folder "C:\Users\YourName\Downloads" --min-age-days 7 --apply
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Use a custom log file
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
downsorter --folder "C:\Users\YourName\Downloads" --apply --log-file my-moves.csv
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Options
|
|
94
|
+
|
|
95
|
+
- `--folder`: Folder to organize. Defaults to the current user's Downloads folder.
|
|
96
|
+
- `--apply`: Actually move files. Without this flag, the script only previews the planned moves.
|
|
97
|
+
- `--min-age-days`: Only move files that have not been modified for at least this many days.
|
|
98
|
+
- `--log-file`: CSV file where completed moves are recorded.
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
1. The script scans the target folder for files.
|
|
103
|
+
2. It matches each file extension against a category list.
|
|
104
|
+
3. It builds a plan showing where each file would move.
|
|
105
|
+
4. In preview mode, it only prints the plan.
|
|
106
|
+
5. In apply mode, it creates category folders, moves files, and logs the results.
|
|
107
|
+
|
|
108
|
+
## Categories
|
|
109
|
+
|
|
110
|
+
The project currently sorts files into:
|
|
111
|
+
|
|
112
|
+
- `Images` (.jpg, .jpeg, .png, .webp, .bmp)
|
|
113
|
+
- `PDFs` (.pdf)
|
|
114
|
+
- `Documents` (.doc, .docx, .txt, .md, .rtf)
|
|
115
|
+
- `Spreadsheets` (.xls, .xlsx, .csv, .tsv)
|
|
116
|
+
- `PPTs` (.ppt, .pptx)
|
|
117
|
+
- `Archives` (.rar, .zip, .7z, .tar, .gz)
|
|
118
|
+
- `Installers` (.exe, .msi)
|
|
119
|
+
- `Code` (.py, .js, .html, .css, .json, .xml, .cpp, .java)
|
|
120
|
+
- `Audio` (.mp3, .wav, .flac, .aac, .ogg)
|
|
121
|
+
- `Videos` (.mp4, .gif, .mov, .mkv, .avi, .webm)
|
|
122
|
+
- `Other` for everything else
|
|
123
|
+
|
|
124
|
+
## Example
|
|
125
|
+
|
|
126
|
+
Run this to preview then apply:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
downsorter --folder "C:\Users\YourName\Downloads"
|
|
130
|
+
downsorter --folder "C:\Users\YourName\Downloads" --apply
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sorter.py,sha256=qMkePEUX86d3jVeusBAI1G5WAhcF0vI6hNsxxnIxglQ,4880
|
|
2
|
+
downsorter-0.2.0.dist-info/licenses/LICENSE,sha256=S_fbX1OLfL7GYfxafKhn4E6-9cDMpbxy3-OgQisZr5A,1087
|
|
3
|
+
downsorter-0.2.0.dist-info/METADATA,sha256=SyTanw3fBs6CafOzB4YpkmTDxnvW6wYBOAiR0TVgRiA,3890
|
|
4
|
+
downsorter-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
downsorter-0.2.0.dist-info/entry_points.txt,sha256=_gGMAUB8BfuaeY3SBVlGPtjliLKhMgIFvZwxwkkESzk,43
|
|
6
|
+
downsorter-0.2.0.dist-info/top_level.txt,sha256=6PgYfJOJ6CnmzIwV3qA6Gso2mBYIELKxdlBfnm1CSzo,7
|
|
7
|
+
downsorter-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sorter
|
sorter.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CATEGORIES = {
|
|
12
|
+
"Images": {".jpg" , ".jpeg" , ".png" , ".webp" , ".bmp"},
|
|
13
|
+
"PDFs" : {".pdf"},
|
|
14
|
+
"Documents" : {".doc" , ".docx" , ".txt" , ".md" , ".rtf"},
|
|
15
|
+
"Spreadsheets" : {".xls" , ".xlsx" , ".csv" , ".tsv"},
|
|
16
|
+
"PPTs" : {".ppt" , ".pptx"},
|
|
17
|
+
"Archives" : {".rar" , ".zip" ,".7z" , ".tar" , ".gz"},
|
|
18
|
+
"Installers" : {".exe" , ".msi"},
|
|
19
|
+
"Code" : {".py" , ".js" , ".html" , ".css" , ".json" , ".xml" , ".cpp" , ".java"},
|
|
20
|
+
"Audio" : {".mp3" , ".wav" , ".flac" , ".aac" , ".ogg"},
|
|
21
|
+
"Videos" : {".mp4" , ".gif" , ".mov" , ".mkv" , ".avi" , ".webm"}
|
|
22
|
+
}
|
|
23
|
+
IGNORE_NAMES = {
|
|
24
|
+
"desktop.ini",
|
|
25
|
+
"thumbs.db",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MovePlan:
|
|
31
|
+
source: Path
|
|
32
|
+
destination: Path
|
|
33
|
+
|
|
34
|
+
def default_downloads_folder():
|
|
35
|
+
return Path.home()/ "Downloads"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def category_in(file_path: Path):
|
|
39
|
+
suffix = file_path.suffix.lower()
|
|
40
|
+
for category, extensions in CATEGORIES.items():
|
|
41
|
+
if suffix in extensions:
|
|
42
|
+
return category
|
|
43
|
+
return "Other"
|
|
44
|
+
|
|
45
|
+
def agecheck(file_path: Path, minimum_days: int):
|
|
46
|
+
if minimum_days <= 0:
|
|
47
|
+
return True
|
|
48
|
+
mod_time = datetime.fromtimestamp(file_path.stat().st_mtime)
|
|
49
|
+
return mod_time <= datetime.now() - timedelta(days= minimum_days)
|
|
50
|
+
|
|
51
|
+
def unique_des(des: Path):
|
|
52
|
+
if not des.exists():
|
|
53
|
+
return des
|
|
54
|
+
counter = 1
|
|
55
|
+
while True:
|
|
56
|
+
candidate = des.with_name(
|
|
57
|
+
f"{des.stem} ({counter}){des.suffix}"
|
|
58
|
+
)
|
|
59
|
+
if not candidate.exists():
|
|
60
|
+
return candidate
|
|
61
|
+
counter = counter + 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_category_folder(downloads_folder: Path, category: str) -> Path:
|
|
65
|
+
canonical = downloads_folder / category
|
|
66
|
+
if canonical.exists():
|
|
67
|
+
return canonical
|
|
68
|
+
|
|
69
|
+
for item in downloads_folder.iterdir():
|
|
70
|
+
if item.is_dir() and item.name.lower() == category.lower():
|
|
71
|
+
temp = downloads_folder / f".{category}.tmp"
|
|
72
|
+
item.rename(temp)
|
|
73
|
+
temp.rename(canonical)
|
|
74
|
+
return canonical
|
|
75
|
+
|
|
76
|
+
return canonical
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def BUILD_plan(downloads_folder:Path , minimum_days:int):
|
|
80
|
+
plans: list[MovePlan] = []
|
|
81
|
+
for item in downloads_folder.iterdir():
|
|
82
|
+
if not item.is_file():
|
|
83
|
+
continue
|
|
84
|
+
if item.name.lower() in IGNORE_NAMES:
|
|
85
|
+
continue
|
|
86
|
+
if not agecheck(item,minimum_days):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
category = category_in(item)
|
|
90
|
+
destination_folder = normalize_category_folder(downloads_folder, category)
|
|
91
|
+
des = unique_des(destination_folder / item.name)
|
|
92
|
+
plans.append(MovePlan(source=item , destination=des))
|
|
93
|
+
return plans
|
|
94
|
+
|
|
95
|
+
def PRINT_plan(plans :list[MovePlan] , apply : bool):
|
|
96
|
+
if not plans:
|
|
97
|
+
print("Nothing to clean , your folder is already tidy lmao")
|
|
98
|
+
return
|
|
99
|
+
action = "Moving" if apply else "Would move"
|
|
100
|
+
for plan in plans:
|
|
101
|
+
file_name = plan.source.name
|
|
102
|
+
destination = plan.destination.parent.name + "\\" + plan.destination.name
|
|
103
|
+
print(action + ": " + file_name + " -> " + destination)
|
|
104
|
+
|
|
105
|
+
def APPLY_plan(plans : list[MovePlan]):
|
|
106
|
+
for plan in plans:
|
|
107
|
+
plan.destination.parent.mkdir(parents=True , exist_ok=True)
|
|
108
|
+
shutil.move(str(plan.source) , str(plan.destination))
|
|
109
|
+
|
|
110
|
+
def parse_args():
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
description="Safely organize your downloads folder by file type"
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--folder",
|
|
116
|
+
type=Path,
|
|
117
|
+
default=default_downloads_folder(),
|
|
118
|
+
help="Folder to clean . By default downloads folder"
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--apply",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Actually move files. Without this flag, the script only previews changes.",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--min-age-days",
|
|
127
|
+
type=int,
|
|
128
|
+
default=0,
|
|
129
|
+
help="Only move files at least this many days old. can be edited",
|
|
130
|
+
)
|
|
131
|
+
return parser.parse_args()
|
|
132
|
+
|
|
133
|
+
def main():
|
|
134
|
+
args = parse_args()
|
|
135
|
+
downloads_folder = args.folder.expanduser().resolve()
|
|
136
|
+
|
|
137
|
+
if not downloads_folder.exists():
|
|
138
|
+
raise SystemExit(f"Folder does not exist: {downloads_folder}")
|
|
139
|
+
if not downloads_folder.is_dir():
|
|
140
|
+
raise SystemExit(f"Not a folder: {downloads_folder}")
|
|
141
|
+
if args.min_age_days < 0:
|
|
142
|
+
raise SystemExit(f"--min-age-days cannot be NEGATIVE")
|
|
143
|
+
|
|
144
|
+
plans = BUILD_plan(downloads_folder, args.min_age_days)
|
|
145
|
+
PRINT_plan(plans , args.apply)
|
|
146
|
+
|
|
147
|
+
if args.apply and plans:
|
|
148
|
+
APPLY_plan(plans)
|
|
149
|
+
print("Done.")
|
|
150
|
+
elif plans:
|
|
151
|
+
print("Preview only. Run again with --apply to move these files")
|
|
152
|
+
|
|
153
|
+
if __name__=="__main__":
|
|
154
|
+
main()
|