519 lines · 17.7 KB
Raw Download
1
"""
2
file_sorter.py — GUI file organizer with optional headless CLI mode.
3
4
Usage:
5
  python file_sorter.py            # open GUI
6
  python file_sorter.py --sort     # headless: sort using saved config, then exit
7
  python file_sorter.py --help     # show CLI help
8
"""
9
10
import sys
11
import json
12
import shutil
13
import argparse
14
from pathlib import Path
15
16
CONFIG_FILE = Path.home() / ".file_sorter_config.json"
17
18
19
BG = "#1a1a2e"
20
PANEL = "#16213e"
21
ACCENT = "#0f3460"
22
HIGHLIGHT = "#e94560"
23
TEXT = "#eaeaea"
24
SUBTEXT = "#888"
25
SUCCESS = "#4caf50"
26
ENTRY_BG = "#0d1b2a"
27
28
ROW_HEIGHT = 26  # px — must match Treeview rowheight
29
30
31
def load_config() -> dict:
32
    """Return {"sources": [...], "rules": [...]} from disk, or defaults."""
33
    if CONFIG_FILE.exists():
34
        try:
35
            data = json.loads(CONFIG_FILE.read_text())
36
            # back-compat: old format was a plain list of rules
37
            if isinstance(data, list):
38
                return {"sources": [], "rules": data}
39
            # back-compat: old format had a single "source" string
40
            if "source" in data and "sources" not in data:
41
                src = data.pop("source").strip()
42
                data["sources"] = [src] if src else []
43
            data.setdefault("sources", [])
44
            return data
45
        except Exception:
46
            pass
47
    return {"sources": [], "rules": []}
48
49
50
def save_config(config: dict):
51
    CONFIG_FILE.write_text(json.dumps(config, indent=2))
52
53
54
def run_sort(sources: list, rules: list) -> tuple:
55
    """
56
    Move files from each folder in *sources* according to *rules*.
57
    Returns (moved, skipped, errors).
58
    """
59
    ext_map: dict = {}
60
    for r in rules:
61
        for e in r["extensions"]:
62
            if e not in ext_map:
63
                ext_map[e] = r["destination"]
64
65
    total_moved, total_skipped, all_errors = 0, 0, []
66
67
    for source in sources:
68
        src = Path(source)
69
        if not src.is_dir():
70
            all_errors.append(f"[{source}] Source folder not found — skipped.")
71
            continue
72
73
        for entry in src.iterdir():
74
            if not entry.is_file():
75
                continue
76
            ext = entry.suffix.lstrip(".").lower()
77
            if ext in ext_map:
78
                dest_dir = Path(ext_map[ext])
79
                try:
80
                    dest_dir.mkdir(parents=True, exist_ok=True)
81
                    target = dest_dir / entry.name
82
                    counter = 1
83
                    while target.exists():
84
                        target = dest_dir / f"{entry.stem}_{counter}{entry.suffix}"
85
                        counter += 1
86
                    shutil.move(str(entry), str(target))
87
                    total_moved += 1
88
                except Exception as exc:
89
                    all_errors.append(f"[{source}] {entry.name}: {exc}")
90
            else:
91
                total_skipped += 1
92
93
    return total_moved, total_skipped, all_errors
94
95
96
def headless_sort():
97
    config = load_config()
98
    sources = [s.strip() for s in config.get("sources", []) if s.strip()]
99
    rules = config.get("rules", [])
100
101
    if not sources:
102
        print(
103
            "ERROR: No source folders saved in config. Open the GUI and set at least one first."
104
        )
105
        sys.exit(1)
106
    if not rules:
107
        print(
108
            "ERROR: No sorting rules saved in config. Open the GUI and add some first."
109
        )
110
        sys.exit(1)
111
112
    print(f"Sorting files in {len(sources)} source folder(s):")
113
    for s in sources:
114
        print(f"  • {s}")
115
    print(f"Using {len(rules)} rule(s) from {CONFIG_FILE}")
116
117
    moved, skipped, errors = run_sort(sources, rules)
118
119
    print(f"Done — {moved} moved, {skipped} skipped (no matching rule).", end="")
120
    if errors:
121
        print(f"  {len(errors)} error(s):")
122
        for err in errors:
123
            print(f"  - {err}")
124
    else:
125
        print()
126
127
128
def launch_gui():
129
    import tkinter as tk
130
    from tkinter import ttk, filedialog, messagebox
131
132
    class FileSorterApp(tk.Tk):
133
        def __init__(self):
134
            super().__init__()
135
            self.title("File Sorter")
136
            self.configure(bg=BG)
137
            self.resizable(True, True)
138
139
            self._config = load_config()
140
            self.rules = self._config.get("rules", [])
141
            self.sources = self._config.get("sources", [])
142
143
            self._build_ui()
144
            self._refresh_sources()
145
            self._refresh_rules()
146
147
            # let geometry settle then lock minimum size
148
            self.update_idletasks()
149
            self.minsize(680, self.winfo_height())
150
151
        def _persist(self, *_):
152
            self._config["rules"] = self.rules
153
            self._config["sources"] = self.sources
154
            save_config(self._config)
155
156
        def _build_ui(self):
157
            # accent strip
158
            tk.Frame(self, bg=HIGHLIGHT, height=4).pack(fill="x")
159
160
            # title
161
            title_bar = tk.Frame(self, bg=BG, pady=12)
162
            title_bar.pack(fill="x", padx=20)
163
            tk.Label(
164
                title_bar,
165
                text="📂 File Sorter",
166
                font=("Courier New", 20, "bold"),
167
                bg=BG,
168
                fg=TEXT,
169
            ).pack(side="left")
170
            tk.Label(
171
                title_bar,
172
                text="Automate your folder hygiene",
173
                font=("Courier New", 10),
174
                bg=BG,
175
                fg=SUBTEXT,
176
            ).pack(side="left", padx=16)
177
178
            # ── Source Folders panel ──────
179
            src_frame = tk.Frame(self, bg=PANEL, pady=10, padx=16)
180
            src_frame.pack(fill="x", padx=20, pady=(0, 4))
181
            tk.Label(
182
                src_frame,
183
                text="SOURCE FOLDERS",
184
                font=("Courier New", 9, "bold"),
185
                bg=PANEL,
186
                fg=HIGHLIGHT,
187
            ).pack(anchor="w")
188
189
            list_row = tk.Frame(src_frame, bg=PANEL)
190
            list_row.pack(fill="x", pady=(4, 0))
191
192
            # listbox + scrollbar
193
            lb_frame = tk.Frame(list_row, bg=PANEL)
194
            lb_frame.pack(side="left", fill="x", expand=True)
195
            sb = tk.Scrollbar(lb_frame, orient="vertical")
196
            self.src_listbox = tk.Listbox(
197
                lb_frame,
198
                bg=ENTRY_BG,
199
                fg=TEXT,
200
                selectbackground=HIGHLIGHT,
201
                selectforeground=TEXT,
202
                relief="flat",
203
                font=("Courier New", 10),
204
                height=4,
205
                yscrollcommand=sb.set,
206
                activestyle="none",
207
            )
208
            sb.config(command=self.src_listbox.yview)
209
            sb.pack(side="right", fill="y")
210
            self.src_listbox.pack(side="left", fill="x", expand=True)
211
212
            # buttons
213
            src_btn_col = tk.Frame(list_row, bg=PANEL)
214
            src_btn_col.pack(side="left", padx=(8, 0), anchor="n")
215
            self._btn(src_btn_col, "+ Add", self._add_source, color=SUCCESS).pack(
216
                fill="x", pady=(0, 4)
217
            )
218
            self._btn(src_btn_col, "Remove", self._remove_source, color="#c0392b").pack(
219
                fill="x"
220
            )
221
222
            # ── Add / Edit Rule panel ──────
223
            add_frame = tk.LabelFrame(
224
                self,
225
                text=" Add / Edit Rule ",
226
                font=("Courier New", 9),
227
                bg=PANEL,
228
                fg=SUBTEXT,
229
                bd=1,
230
                padx=14,
231
                pady=10,
232
            )
233
            add_frame.pack(fill="x", padx=20, pady=6)
234
235
            ext_row = tk.Frame(add_frame, bg=PANEL)
236
            ext_row.pack(fill="x", pady=(0, 6))
237
            tk.Label(
238
                ext_row,
239
                text="Extensions (comma-separated, e.g. jpg, png, gif):",
240
                font=("Courier New", 9),
241
                bg=PANEL,
242
                fg=TEXT,
243
            ).pack(anchor="w")
244
            self.ext_var = tk.StringVar()
245
            tk.Entry(
246
                ext_row,
247
                textvariable=self.ext_var,
248
                bg=ENTRY_BG,
249
                fg=TEXT,
250
                insertbackground=TEXT,
251
                relief="flat",
252
                font=("Courier New", 11),
253
                bd=6,
254
            ).pack(fill="x")
255
256
            dst_row = tk.Frame(add_frame, bg=PANEL)
257
            dst_row.pack(fill="x", pady=(0, 6))
258
            tk.Label(
259
                dst_row,
260
                text="Destination folder:",
261
                font=("Courier New", 9),
262
                bg=PANEL,
263
                fg=TEXT,
264
            ).pack(anchor="w")
265
            dest_inp = tk.Frame(dst_row, bg=PANEL)
266
            dest_inp.pack(fill="x")
267
            self.dst_var = tk.StringVar()
268
            tk.Entry(
269
                dest_inp,
270
                textvariable=self.dst_var,
271
                bg=ENTRY_BG,
272
                fg=TEXT,
273
                insertbackground=TEXT,
274
                relief="flat",
275
                font=("Courier New", 11),
276
                bd=6,
277
            ).pack(side="left", fill="x", expand=True)
278
            self._btn(dest_inp, "Browse", self._browse_dest).pack(
279
                side="left", padx=(8, 0)
280
            )
281
282
            btn_row = tk.Frame(add_frame, bg=PANEL)
283
            btn_row.pack(fill="x", pady=(4, 0))
284
            self._btn(btn_row, "+ Add Rule", self._add_rule, color=SUCCESS).pack(
285
                side="left"
286
            )
287
            self._btn(btn_row, "Edit Selected", self._update_rule).pack(
288
                side="left", padx=8
289
            )
290
            self._btn(
291
                btn_row, "Delete Selected", self._delete_rule, color="#c0392b"
292
            ).pack(side="left")
293
294
            # ── Rules table ──────
295
            tbl_frame = tk.Frame(self, bg=BG)
296
            tbl_frame.pack(fill="x", padx=20, pady=(0, 4))
297
            tk.Label(
298
                tbl_frame,
299
                text="RULES",
300
                font=("Courier New", 9, "bold"),
301
                bg=BG,
302
                fg=HIGHLIGHT,
303
            ).pack(anchor="w", pady=(4, 2))
304
305
            style = ttk.Style()
306
            style.theme_use("clam")
307
            style.configure(
308
                "Treeview",
309
                background=ENTRY_BG,
310
                foreground=TEXT,
311
                fieldbackground=ENTRY_BG,
312
                rowheight=ROW_HEIGHT,
313
                font=("Courier New", 10),
314
            )
315
            style.configure(
316
                "Treeview.Heading",
317
                background=ACCENT,
318
                foreground=TEXT,
319
                font=("Courier New", 10, "bold"),
320
                relief="flat",
321
            )
322
            style.map("Treeview", background=[("selected", HIGHLIGHT)])
323
324
            cols = ("extensions", "destination")
325
            self.tree = ttk.Treeview(
326
                tbl_frame,
327
                columns=cols,
328
                show="headings",
329
                selectmode="browse",
330
                style="Treeview",
331
                height=0,
332
            )
333
            self.tree.heading("extensions", text="Extensions")
334
            self.tree.heading("destination", text="Destination Folder")
335
            self.tree.column("extensions", width=200, minwidth=120)
336
            self.tree.column("destination", width=520, minwidth=200)
337
            self.tree.pack(fill="x")
338
            self.tree.bind("<<TreeviewSelect>>", self._on_select)
339
340
            # ── Bottom bar ──────
341
            bar = tk.Frame(self, bg=BG, pady=10)
342
            bar.pack(fill="x", padx=20)
343
            self.status_var = tk.StringVar(value="Ready.")
344
            tk.Label(
345
                bar,
346
                textvariable=self.status_var,
347
                font=("Courier New", 9),
348
                bg=BG,
349
                fg=SUBTEXT,
350
            ).pack(side="left")
351
            self._btn(
352
                bar, "Sort Now", self._sort_files, color=HIGHLIGHT, font_size=12
353
            ).pack(side="right")
354
355
        def _btn(self, parent, text, cmd, color=ACCENT, font_size=10):
356
            return tk.Button(
357
                parent,
358
                text=text,
359
                command=cmd,
360
                bg=color,
361
                fg=TEXT,
362
                activebackground=HIGHLIGHT,
363
                activeforeground=TEXT,
364
                relief="flat",
365
                cursor="hand2",
366
                font=("Courier New", font_size, "bold"),
367
                padx=10,
368
                pady=4,
369
            )
370
371
        # ── Source folder helpers ──────
372
373
        def _refresh_sources(self):
374
            self.src_listbox.delete(0, "end")
375
            for s in self.sources:
376
                self.src_listbox.insert("end", s)
377
378
        def _add_source(self):
379
            d = filedialog.askdirectory(title="Select source folder")
380
            if d and d not in self.sources:
381
                self.sources.append(d)
382
                self._persist()
383
                self._refresh_sources()
384
                self.status_var.set(f"Source added: {d}")
385
386
        def _remove_source(self):
387
            sel = self.src_listbox.curselection()
388
            if not sel:
389
                return
390
            removed = self.sources.pop(sel[0])
391
            self._persist()
392
            self._refresh_sources()
393
            self.status_var.set(f"Source removed: {removed}")
394
395
        # ── Destination browse ──────
396
397
        def _browse_dest(self):
398
            d = filedialog.askdirectory(title="Select destination folder")
399
            if d:
400
                self.dst_var.set(d)
401
402
        # ── Rule helpers ──────
403
404
        def _parse_exts(self, raw: str) -> list:
405
            return [e.strip().lstrip(".").lower() for e in raw.split(",") if e.strip()]
406
407
        def _refresh_rules(self):
408
            self.tree.delete(*self.tree.get_children())
409
            for i, r in enumerate(self.rules):
410
                self.tree.insert(
411
                    "",
412
                    "end",
413
                    iid=str(i),
414
                    values=(", ".join(r["extensions"]), r["destination"]),
415
                )
416
            self.tree.configure(height=len(self.rules))
417
            self.update_idletasks()
418
419
        def _on_select(self, _=None):
420
            sel = self.tree.selection()
421
            if not sel:
422
                return
423
            r = self.rules[int(sel[0])]
424
            self.ext_var.set(", ".join(r["extensions"]))
425
            self.dst_var.set(r["destination"])
426
427
        def _add_rule(self):
428
            exts = self._parse_exts(self.ext_var.get())
429
            dst = self.dst_var.get().strip()
430
            if not exts or not dst:
431
                messagebox.showwarning(
432
                    "Missing info",
433
                    "Please enter at least one extension and a destination folder.",
434
                )
435
                return
436
            self.rules.append({"extensions": exts, "destination": dst})
437
            self._persist()
438
            self._refresh_rules()
439
            self.ext_var.set("")
440
            self.dst_var.set("")
441
            self.status_var.set(f"Rule added: {', '.join(exts)} -> {dst}")
442
443
        def _update_rule(self):
444
            sel = self.tree.selection()
445
            if not sel:
446
                messagebox.showinfo("No selection", "Select a rule to update.")
447
                return
448
            exts = self._parse_exts(self.ext_var.get())
449
            dst = self.dst_var.get().strip()
450
            if not exts or not dst:
451
                messagebox.showwarning(
452
                    "Missing info", "Please fill in extensions and destination."
453
                )
454
                return
455
            self.rules[int(sel[0])] = {"extensions": exts, "destination": dst}
456
            self._persist()
457
            self._refresh_rules()
458
            self.status_var.set("Rule updated.")
459
460
        def _delete_rule(self):
461
            sel = self.tree.selection()
462
            if not sel:
463
                messagebox.showinfo("No selection", "Select a rule to delete.")
464
                return
465
            removed = self.rules.pop(int(sel[0]))
466
            self._persist()
467
            self._refresh_rules()
468
            self.status_var.set(f"Deleted rule for: {', '.join(removed['extensions'])}")
469
470
        def _sort_files(self):
471
            if not self.sources:
472
                messagebox.showwarning(
473
                    "No sources", "Please add at least one source folder."
474
                )
475
                return
476
            if not self.rules:
477
                messagebox.showinfo(
478
                    "No rules", "Add at least one sorting rule before sorting."
479
                )
480
                return
481
            self._persist()
482
            moved, skipped, errors = run_sort(self.sources, self.rules)
483
484
            summary = f"{moved} file(s) moved, {skipped} skipped (no matching rule)."
485
            if errors:
486
                summary += f"  {len(errors)} error(s)."
487
                messagebox.showwarning(
488
                    "Sort complete with errors",
489
                    summary + "\n\n" + "\n".join(errors[:10]),
490
                )
491
            else:
492
                messagebox.showinfo("Sort complete", summary)
493
            self.status_var.set(summary)
494
495
    FileSorterApp().mainloop()
496
497
498
if __name__ == "__main__":
499
    parser = argparse.ArgumentParser(
500
        description="File Sorter — GUI organizer with headless mode.",
501
        formatter_class=argparse.RawDescriptionHelpFormatter,
502
        epilog="""
503
Examples:
504
  python file_sorter.py            open the GUI
505
  python file_sorter.py --sort     sort files silently using saved config, then exit
506
        """,
507
    )
508
    parser.add_argument(
509
        "--sort",
510
        action="store_true",
511
        help="Headless mode: sort files using the saved source folders and rules, then exit.",
512
    )
513
    args = parser.parse_args()
514
515
    if args.sort:
516
        headless_sort()
517
    else:
518
        launch_gui()
519