filecraft-cli 1.0.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.
File without changes
@@ -0,0 +1,171 @@
1
+ # -------------------------
2
+ # Images
3
+ # -------------------------
4
+
5
+ IMAGE_EXTENSIONS = {
6
+ ".jpg",
7
+ ".jpeg",
8
+ ".png",
9
+ ".gif",
10
+ ".bmp",
11
+ ".tiff",
12
+ ".tif",
13
+ ".webp",
14
+ ".svg",
15
+ ".heic",
16
+ ".heif",
17
+ ".ico",
18
+ ".raw",
19
+ ".cr2",
20
+ ".nef",
21
+ ".orf",
22
+ ".sr2",
23
+ ".psd",
24
+ ".ai",
25
+ ".eps",
26
+ }
27
+
28
+ # -------------------------
29
+ # Documents
30
+ # -------------------------
31
+
32
+ DOCUMENT_EXTENSIONS = {
33
+ ".pdf",
34
+ ".doc",
35
+ ".docx",
36
+ ".odt",
37
+ ".rtf",
38
+ ".txt",
39
+ ".md",
40
+ ".pages",
41
+ ".tex",
42
+ }
43
+
44
+ # -------------------------
45
+ # Spreadsheets
46
+ # -------------------------
47
+
48
+ SPREADSHEET_EXTENSIONS = {".xls", ".xlsx", ".xlsm", ".ods", ".csv", ".tsv"}
49
+
50
+ # -------------------------
51
+ # Presentations
52
+ # -------------------------
53
+
54
+ PRESENTATION_EXTENSIONS = {".ppt", ".pptx", ".odp", ".key"}
55
+
56
+ # -------------------------
57
+ # Videos
58
+ # -------------------------
59
+
60
+ VIDEO_EXTENSIONS = {
61
+ ".mp4",
62
+ ".mkv",
63
+ ".mov",
64
+ ".avi",
65
+ ".wmv",
66
+ ".flv",
67
+ ".webm",
68
+ ".mpeg",
69
+ ".mpg",
70
+ ".3gp",
71
+ ".m4v",
72
+ }
73
+
74
+ # -------------------------
75
+ # Audio
76
+ # -------------------------
77
+
78
+ AUDIO_EXTENSIONS = {
79
+ ".mp3",
80
+ ".wav",
81
+ ".aac",
82
+ ".flac",
83
+ ".ogg",
84
+ ".m4a",
85
+ ".wma",
86
+ ".aiff",
87
+ ".alac",
88
+ }
89
+
90
+ # -------------------------
91
+ # Archives / Compressed
92
+ # -------------------------
93
+
94
+ ARCHIVE_EXTENSIONS = {
95
+ ".zip",
96
+ ".rar",
97
+ ".7z",
98
+ ".tar",
99
+ ".gz",
100
+ ".bz2",
101
+ ".xz",
102
+ ".tgz",
103
+ ".tar.gz",
104
+ }
105
+
106
+ # -------------------------
107
+ # Executables
108
+ # -------------------------
109
+
110
+ EXECUTABLE_EXTENSIONS = {
111
+ ".exe",
112
+ ".msi",
113
+ ".apk",
114
+ ".app",
115
+ ".deb",
116
+ ".rpm",
117
+ ".dmg",
118
+ ".bin",
119
+ ".sh",
120
+ ".bat",
121
+ }
122
+
123
+ # -------------------------
124
+ # Code / Programming
125
+ # -------------------------
126
+
127
+ CODE_EXTENSIONS = {
128
+ ".py",
129
+ ".js",
130
+ ".ts",
131
+ ".jsx",
132
+ ".tsx",
133
+ ".java",
134
+ ".c",
135
+ ".cpp",
136
+ ".h",
137
+ ".hpp",
138
+ ".cs",
139
+ ".go",
140
+ ".rs",
141
+ ".php",
142
+ ".rb",
143
+ ".swift",
144
+ ".kt",
145
+ ".html",
146
+ ".css",
147
+ ".scss",
148
+ ".json",
149
+ ".xml",
150
+ ".yaml",
151
+ ".yml",
152
+ ".sql",
153
+ }
154
+
155
+ # -------------------------
156
+ # Fonts
157
+ # -------------------------
158
+
159
+ FONT_EXTENSIONS = {".ttf", ".otf", ".woff", ".woff2", ".eot"}
160
+
161
+ # -------------------------
162
+ # Disk Images
163
+ # -------------------------
164
+
165
+ DISK_IMAGE_EXTENSIONS = {".iso", ".img", ".vhd", ".vmdk"}
166
+
167
+ # -------------------------
168
+ # HISTORY FILE NAME PREFIX
169
+ # -------------------------
170
+
171
+ HISTORY_FILE_PREFIX = ".organizer_history_"
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SeparateChoices(Enum):
5
+ EXTENSION = "extension"
6
+ DATE = "date"
7
+ EXTENSION_AND_DATE = "extension_and_date"
8
+ FILE = "file"
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+ import json
3
+ from typing import Dict, Optional
4
+
5
+ from file_organiser_python.utils import build_non_conflicting_path
6
+
7
+
8
+ def save_history(
9
+ history_path: Path,
10
+ revert_map: Dict[str, str],
11
+ operation: str = "rename",
12
+ ) -> None:
13
+ data = {
14
+ "operation": operation,
15
+ "mappings": revert_map,
16
+ }
17
+
18
+ history_path.parent.mkdir(parents=True, exist_ok=True)
19
+ with open(history_path, "w", encoding="utf-8") as f:
20
+ json.dump(data, f, indent=4)
21
+
22
+
23
+ def load_latest_history(directory: Path) -> Path | None:
24
+ history_files = list(directory.glob(".organizer_history_*.json"))
25
+
26
+ if not history_files:
27
+ return None
28
+
29
+ return max(history_files, key=lambda f: f.stat().st_mtime)
30
+
31
+
32
+ def read_history(history_path: Path) -> Dict[str, str]:
33
+ with open(history_path, "r", encoding="utf-8") as f:
34
+ data = json.load(f)
35
+
36
+ return data.get("mappings", {})
37
+
38
+
39
+ def delete_history(history_path: Path) -> None:
40
+ if history_path.exists():
41
+ history_path.unlink()
42
+
43
+
44
+ def revert_history(
45
+ history_path: Optional[Path] = None,
46
+ directory: Optional[Path] = None,
47
+ dry_run: bool = False,
48
+ delete_after_revert: bool = True,
49
+ ) -> int:
50
+ if history_path is None:
51
+ if directory is None:
52
+ directory = Path.cwd()
53
+
54
+ history_path = load_latest_history(directory)
55
+ if history_path is None:
56
+ print(f"No history file found in {directory}")
57
+ return 0
58
+
59
+ with open(history_path, "r", encoding="utf-8") as f:
60
+ data = json.load(f)
61
+
62
+ mappings: Dict[str, str] = data.get("mappings", {})
63
+ if not mappings:
64
+ print(f"No mappings found in history file: {history_path}")
65
+ return 0
66
+
67
+ reverted_count = 0
68
+ for current, original in mappings.items():
69
+ current_path = Path(current)
70
+ original_path = Path(original)
71
+
72
+ if not current_path.exists():
73
+ continue
74
+
75
+ if dry_run:
76
+ print(f"[DRY RUN] Would move {current_path} -> {original_path}")
77
+ reverted_count += 1
78
+ continue
79
+
80
+ original_path.parent.mkdir(parents=True, exist_ok=True)
81
+ destination_path = build_non_conflicting_path(original_path)
82
+ current_path.rename(destination_path)
83
+ reverted_count += 1
84
+
85
+ if reverted_count and delete_after_revert and not dry_run:
86
+ delete_history(history_path)
87
+
88
+ return reverted_count
@@ -0,0 +1,259 @@
1
+ from pathlib import Path
2
+ from datetime import date
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from file_organiser_python.enums import SeparateChoices
8
+ from file_organiser_python.history import revert_history
9
+ from file_organiser_python.organizer import (
10
+ FileOrganizer,
11
+ MissingTargetDirectoryError,
12
+ TargetPathNotDirectoryError,
13
+ )
14
+ from file_organiser_python.utils import validate_directory
15
+
16
+ app = typer.Typer()
17
+
18
+
19
+ def _resolve_target_directory(
20
+ target_dir: Optional[Path],
21
+ dry_run: bool,
22
+ ) -> Optional[Path]:
23
+ if not target_dir:
24
+ return None
25
+
26
+ if target_dir.exists():
27
+ if not target_dir.is_dir():
28
+ raise typer.BadParameter(
29
+ f"Path is not a directory: {target_dir}",
30
+ param_hint="--target-dir",
31
+ )
32
+ return target_dir
33
+
34
+ if dry_run:
35
+ typer.echo(f"[DRY RUN] Target directory does not exist: {target_dir}")
36
+ return target_dir
37
+
38
+ if typer.confirm(
39
+ f"Target directory '{target_dir}' does not exist. Create it?",
40
+ default=False,
41
+ ):
42
+ try:
43
+ target_dir.mkdir(parents=True, exist_ok=True)
44
+ except OSError as exc:
45
+ raise typer.BadParameter(
46
+ f"Unable to create target directory: {target_dir}",
47
+ param_hint="--target-dir",
48
+ ) from exc
49
+ typer.echo(f"Created target directory: {target_dir.resolve()}")
50
+ return target_dir
51
+
52
+ raise typer.BadParameter(
53
+ f"Target directory does not exist: {target_dir}",
54
+ param_hint="--target-dir",
55
+ )
56
+
57
+
58
+ def _validate_optional_directory(path: Optional[Path], option_name: str) -> None:
59
+ if not path:
60
+ return
61
+
62
+ try:
63
+ validate_directory(path)
64
+ except ValueError as exc:
65
+ raise typer.BadParameter(str(exc), param_hint=option_name) from exc
66
+
67
+
68
+ def _validate_optional_iso_date(sort_date: Optional[str]) -> None:
69
+ if not sort_date:
70
+ return
71
+
72
+ try:
73
+ date.fromisoformat(sort_date)
74
+ except ValueError as exc:
75
+ raise typer.BadParameter(
76
+ "Date must be in YYYY-MM-DD format.",
77
+ param_hint="--date",
78
+ ) from exc
79
+
80
+
81
+ def _validate_required_directories(paths: list[Path], option_name: str) -> None:
82
+ if not paths:
83
+ raise typer.BadParameter(
84
+ "At least one working directory is required.",
85
+ param_hint=option_name,
86
+ )
87
+
88
+ for path in paths:
89
+ try:
90
+ validate_directory(path)
91
+ except ValueError as exc:
92
+ raise typer.BadParameter(str(exc), param_hint=option_name) from exc
93
+
94
+
95
+ @app.command()
96
+ def rename(
97
+ target_dir: Optional[Path] = typer.Option(
98
+ None, help="Where renamed files are moved."
99
+ ),
100
+ working_dir: Optional[Path] = typer.Option(
101
+ None, help="Source directory to process."
102
+ ),
103
+ dry_run: bool = typer.Option(False, help="Preview actions without making changes."),
104
+ history: bool = typer.Option(False, "--history", help="Save operation history."),
105
+ renameWith: Optional[str] = typer.Option(
106
+ None,
107
+ "--rename-with",
108
+ help="Base name to use for renamed files (e.g. 'file' to get 'file_1.pdf', 'file_2.pdf', etc.).",
109
+ ),
110
+ ) -> None:
111
+ _validate_optional_directory(working_dir, "--working-dir")
112
+ target_dir = _resolve_target_directory(target_dir, dry_run=dry_run)
113
+
114
+ try:
115
+ organizer = FileOrganizer(
116
+ target_dir=target_dir,
117
+ working_dir=working_dir,
118
+ dry_run=dry_run,
119
+ save_history=history,
120
+ renameWith=renameWith,
121
+ )
122
+ except (MissingTargetDirectoryError, TargetPathNotDirectoryError) as exc:
123
+ raise typer.BadParameter(str(exc), param_hint="--target-dir") from exc
124
+ organizer.rename()
125
+
126
+
127
+ @app.command()
128
+ def separate(
129
+ mode: SeparateChoices = typer.Option(
130
+ SeparateChoices.EXTENSION,
131
+ "--mode",
132
+ help="How to separate files: extension, date, extension_and_date, file.",
133
+ ),
134
+ extension: Optional[str] = typer.Option(
135
+ None,
136
+ "--extension",
137
+ help="Extension to filter, e.g. .pdf or pdf.",
138
+ ),
139
+ file_type: Optional[str] = typer.Option(
140
+ None,
141
+ "--file-type",
142
+ help="File type filter for --mode file (e.g. documents, images, pdf).",
143
+ ),
144
+ sort_date: Optional[str] = typer.Option(
145
+ None,
146
+ "--date",
147
+ help="Date in YYYY-MM-DD format. Defaults to today when mode uses date.",
148
+ ),
149
+ target_dir: Optional[Path] = typer.Option(
150
+ None, help="Where separated files are moved."
151
+ ),
152
+ working_dir: Optional[Path] = typer.Option(
153
+ None, help="Source directory to process."
154
+ ),
155
+ dry_run: bool = typer.Option(False, help="Preview actions without making changes."),
156
+ history: bool = typer.Option(False, "--history", help="Save operation history."),
157
+ ) -> None:
158
+ _validate_optional_directory(working_dir, "--working-dir")
159
+ _validate_optional_iso_date(sort_date)
160
+ target_dir = _resolve_target_directory(target_dir, dry_run=dry_run)
161
+
162
+ normalized_extension = f".{extension.lstrip('.').lower()}" if extension else None
163
+
164
+ try:
165
+ organizer = FileOrganizer(
166
+ target_dir=target_dir,
167
+ working_dir=working_dir,
168
+ dry_run=dry_run,
169
+ save_history=history,
170
+ sort_date=sort_date,
171
+ sort_extension=normalized_extension,
172
+ file_type=file_type,
173
+ separate_choice=mode,
174
+ )
175
+ except (MissingTargetDirectoryError, TargetPathNotDirectoryError) as exc:
176
+ raise typer.BadParameter(str(exc), param_hint="--target-dir") from exc
177
+ organizer.separate()
178
+
179
+
180
+ @app.command()
181
+ def revert(
182
+ directory: Optional[Path] = typer.Option(
183
+ None,
184
+ help="Directory containing history files. Defaults to current directory.",
185
+ ),
186
+ history_file: Optional[Path] = typer.Option(
187
+ None,
188
+ "--history-file",
189
+ help="Specific history file path to revert.",
190
+ ),
191
+ dry_run: bool = typer.Option(
192
+ False, help="Preview revert actions without making changes."
193
+ ),
194
+ keep_history: bool = typer.Option(
195
+ False,
196
+ "--keep-history",
197
+ help="Do not delete history file after successful revert.",
198
+ ),
199
+ ) -> None:
200
+ _validate_optional_directory(directory, "--directory")
201
+
202
+ reverted = revert_history(
203
+ history_path=history_file,
204
+ directory=directory,
205
+ dry_run=dry_run,
206
+ delete_after_revert=not keep_history,
207
+ )
208
+ print(f"Reverted {reverted} file(s).")
209
+
210
+
211
+ @app.command()
212
+ def merge(
213
+ mode: SeparateChoices = typer.Option(
214
+ SeparateChoices.EXTENSION,
215
+ "--mode",
216
+ help="How to merge files: extension, date, extension_and_date, file.",
217
+ ),
218
+ extension: Optional[str] = typer.Option(
219
+ None,
220
+ "--extension",
221
+ help="Extension to filter, e.g. .pdf or pdf.",
222
+ ),
223
+ sort_date: Optional[str] = typer.Option(
224
+ None,
225
+ "--date",
226
+ help="Date in YYYY-MM-DD format. Defaults to today when mode uses date.",
227
+ ),
228
+ target_dir: Optional[Path] = typer.Option(
229
+ None, help="Where merged files are moved."
230
+ ),
231
+ working_dirs: list[Path] = typer.Option(
232
+ ..., "--working-dir", help="One or more source directories to merge from."
233
+ ),
234
+ dry_run: bool = typer.Option(False, help="Preview actions without making changes."),
235
+ history: bool = typer.Option(False, "--history", help="Save operation history."),
236
+ ) -> None:
237
+ _validate_required_directories(working_dirs, "--working-dir")
238
+ _validate_optional_iso_date(sort_date)
239
+ target_dir = _resolve_target_directory(target_dir, dry_run=dry_run)
240
+
241
+ normalized_extension = f".{extension.lstrip('.').lower()}" if extension else None
242
+
243
+ try:
244
+ organizer = FileOrganizer(
245
+ target_dir=target_dir,
246
+ working_dirs=working_dirs,
247
+ dry_run=dry_run,
248
+ save_history=history,
249
+ sort_date=sort_date,
250
+ sort_extension=normalized_extension,
251
+ separate_choice=mode,
252
+ )
253
+ except (MissingTargetDirectoryError, TargetPathNotDirectoryError) as exc:
254
+ raise typer.BadParameter(str(exc), param_hint="--target-dir") from exc
255
+ organizer.merge()
256
+
257
+
258
+ if __name__ == "__main__":
259
+ app()