Skip to content

Commit a2c436d

Browse files
Merge pull request #25 from ZylosCore/logs-fix
Logs fix
2 parents e8d638d + ccc7dbc commit a2c436d

7 files changed

Lines changed: 192 additions & 81 deletions

File tree

project_info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<project>
33
<name>BulkFolder</name>
4-
<version>1.7.8</version>
4+
<version>1.7.10</version>
55
<description>The safest and fastest bulk organizer and renamer.</description>
66
<author>Achraf KHABAR / Open Source Community</author>
77
<repository>https://github.com/Ashraf-Khabar/bulkfolder</repository>

src/bulkfolder/executor.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
from __future__ import annotations
22
from pathlib import Path
3+
from typing import Callable, Optional
34
from .domain import Plan, ActionType
45
from .journal import JournalEntry, append_entries, journal_path_for, now_iso
56

6-
def apply_plan(plan: Plan, *, allow_conflicts: bool = False) -> int:
7+
def apply_plan(
8+
plan: Plan,
9+
*,
10+
allow_conflicts: bool = False,
11+
on_progress: Optional[Callable[[str, str], None]] = None
12+
) -> int:
13+
"""
14+
Applies the given plan by executing file operations.
15+
16+
Args:
17+
plan: The Plan object containing items to process.
18+
allow_conflicts: If True, overwrites existing files.
19+
on_progress: Optional callback function(message, level) for logging.
20+
"""
721
if plan.has_conflicts and not allow_conflicts:
822
raise RuntimeError("Plan has conflicts. Resolve before applying.")
923

@@ -16,15 +30,31 @@ def apply_plan(plan: Plan, *, allow_conflicts: bool = False) -> int:
1630
continue
1731

1832
src, dst = item.src, item.dst
19-
dst.parent.mkdir(parents=True, exist_ok=True)
20-
21-
if dst.exists() and not allow_conflicts:
22-
raise FileExistsError(dst)
23-
24-
src.replace(dst)
25-
26-
entries.append(JournalEntry(op="move", src=str(src), dst=str(dst), ts=now_iso()))
27-
applied += 1
33+
34+
try:
35+
dst.parent.mkdir(parents=True, exist_ok=True)
36+
37+
if dst.exists() and not allow_conflicts:
38+
if on_progress:
39+
on_progress(f"Conflict: {dst.name} already exists. Skipping.", "WARNING")
40+
continue
41+
42+
src.replace(dst)
43+
44+
# Logging the success of the operation
45+
if on_progress:
46+
on_progress(f"Moved: {src.name} -> {dst.parent.name}/", "SUCCESS")
47+
48+
entries.append(JournalEntry(op="move", src=str(src), dst=str(dst), ts=now_iso()))
49+
applied += 1
50+
51+
except Exception as e:
52+
if on_progress:
53+
on_progress(f"Error processing {src.name}: {str(e)}", "ERROR")
2854

2955
append_entries(jpath, entries)
30-
return applied
56+
57+
if on_progress:
58+
on_progress(f"Task completed. {applied} operations applied.", "INFO")
59+
60+
return applied

src/bulkfolder/ui/actions.py

Lines changed: 101 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import shutil
66
import webbrowser
7+
import threading
78
from datetime import datetime
89
from pathlib import Path
910
from tkinter import filedialog, messagebox
@@ -76,55 +77,88 @@ def choose_folder(app) -> None:
7677
def toggle_subfolders(app, enabled: bool) -> None:
7778
app.ui_state.include_subfolders = enabled
7879

79-
def toggle_move_by_ext(app, enabled: bool) -> None:
80-
app.ui_state.move_by_ext = enabled
81-
8280
def scan_and_plan(app) -> None:
83-
"""Scan folder and build virtual plan without Find/Replace rules."""
81+
"""Scan folder and build virtual plan in a background thread."""
8482
root = getattr(app.ui_state, "root", None)
8583
if not root:
8684
messagebox.showwarning("BulkFolder", "Please choose a folder first.")
8785
return
8886

8987
app.set_status("Scanning...")
88+
app.topbar_view.show_loading(True)
9089

91-
# 1. Scan drive
92-
scanned = scan_folder(app.ui_state.root, include_subfolders=app.ui_state.include_subfolders)
93-
app.last_scan = scanned
94-
95-
# 2. Apply Move rule only (Find/Replace removed)
96-
move_rule = MoveByExtensionRule(DEFAULT_MAPPING) if app.ui_state.move_by_ext else None
97-
plan = build_plan(app.ui_state.root, scanned, replace_rule=None, move_rule=move_rule)
98-
app.last_plan = plan
99-
100-
# 3. Calculate statistics
101-
files_count = sum(1 for x in scanned if x.is_file)
102-
planned = sum(1 for it in plan.items if it.action.value != "skip")
103-
conflicts = sum(1 for it in plan.items if it.conflict)
104-
total_size = sum(x.size for x in scanned if x.is_file)
105-
106-
# 4. Update UI
107-
app.cards_view.set_values(str(files_count), str(planned), str(conflicts), app.human_bytes(total_size))
108-
app.preview_view.render(plan)
109-
app.dashboard_view.render(scanned, mapping=DEFAULT_MAPPING)
110-
111-
app.organizer_panel.set_apply_enabled((not plan.has_conflicts) and planned > 0)
112-
app.organizer_panel.set_undo_enabled(True)
113-
app.tabs.set("Preview")
90+
def run_scan():
91+
try:
92+
# 1. Heavy Scanning Logic
93+
scanned = scan_folder(app.ui_state.root, include_subfolders=app.ui_state.include_subfolders)
94+
move_rule = MoveByExtensionRule(DEFAULT_MAPPING) if app.ui_state.move_by_ext else None
95+
plan = build_plan(app.ui_state.root, scanned, replace_rule=None, move_rule=move_rule)
96+
97+
# Statistics calculation
98+
files_count = sum(1 for x in scanned if x.is_file)
99+
planned = sum(1 for it in plan.items if it.action.value != "skip")
100+
conflicts = sum(1 for it in plan.items if it.conflict)
101+
total_size = sum(x.size for x in scanned if x.is_file)
102+
103+
# 2. Update UI back on main thread
104+
app.after(0, lambda: finalize_scan(scanned, plan, files_count, planned, conflicts, total_size))
105+
106+
except Exception as e:
107+
app.after(0, lambda: handle_scan_error(str(e)))
108+
109+
def finalize_scan(scanned, plan, files_count, planned, conflicts, total_size):
110+
app.last_scan = scanned
111+
app.last_plan = plan
112+
app.cards_view.set_values(str(files_count), str(planned), str(conflicts), app.human_bytes(total_size))
113+
app.preview_view.render(plan)
114+
app.dashboard_view.render(scanned, mapping=DEFAULT_MAPPING)
115+
116+
app.organizer_panel.set_apply_enabled((not plan.has_conflicts) and planned > 0)
117+
app.organizer_panel.set_undo_enabled(True)
118+
app.tabs.set("Preview")
119+
120+
app.topbar_view.show_loading(False)
121+
app.set_status("Ready")
122+
app.log(f"Scan complete: {files_count} files found.", level="INFO")
123+
124+
def handle_scan_error(error_msg: str):
125+
app.topbar_view.show_loading(False)
126+
app.set_status("Error during scan.")
127+
messagebox.showerror("BulkFolder", f"Scan failed: {error_msg}")
128+
129+
# Start the background thread
130+
threading.Thread(target=run_scan, daemon=True).start()
114131

115132
def apply_plan(app) -> None:
116-
"""Physically apply changes."""
133+
"""Physically apply changes in background."""
117134
if not app.last_plan or sum(1 for it in app.last_plan.items if it.action.value != "skip") == 0: return
118135
if app.last_plan.has_conflicts:
119136
messagebox.showerror("BulkFolder", "Plan has conflicts.")
120137
return
138+
121139
if _ask_confirm(app, "BulkFolder", "Apply changes?"):
122-
try:
123-
n = exec_apply_plan(app.last_plan, allow_conflicts=False)
124-
app.set_status("Applied.")
125-
scan_and_plan(app)
126-
except Exception as e:
127-
messagebox.showerror("BulkFolder", f"Apply failed:\n{e}")
140+
app.set_status("Applying changes...")
141+
app.topbar_view.show_loading(True)
142+
143+
def run_apply():
144+
try:
145+
# Execution with our custom logging callback (UIState.log)
146+
n = exec_apply_plan(app.last_plan, allow_conflicts=False, on_progress=app.log)
147+
app.after(0, lambda: finalize_apply())
148+
except Exception as e:
149+
app.after(0, lambda: handle_apply_error(str(e)))
150+
151+
def finalize_apply():
152+
app.topbar_view.show_loading(False)
153+
app.set_status("Ready")
154+
scan_and_plan(app) # Refresh after apply
155+
156+
def handle_apply_error(error_msg: str):
157+
app.topbar_view.show_loading(False)
158+
app.set_status("Error applying.")
159+
messagebox.showerror("BulkFolder", f"Apply failed: {error_msg}")
160+
161+
threading.Thread(target=run_apply, daemon=True).start()
128162

129163
def undo_last_ops(app) -> None:
130164
"""Revert last applied operations."""
@@ -137,27 +171,29 @@ def undo_last_ops(app) -> None:
137171
except Exception as e:
138172
messagebox.showerror("BulkFolder", f"Undo failed:\n{e}")
139173

140-
# Duplicates action remains in core but no longer called from Organizer Panel
141174
def find_duplicates_action(app) -> None:
142-
"""Scans for duplicate files."""
175+
"""Scans for duplicate files in background."""
143176
root = getattr(app.ui_state, "root", None)
144177
if not root: return
145178

146-
min_kb = int(app.settings.duplicate_min_size_kb) if getattr(app, "settings", None) else 1
147-
scanned = app.last_scan or scan_folder(app.ui_state.root, include_subfolders=app.ui_state.include_subfolders)
148-
app.last_scan = scanned
149-
150-
groups = find_duplicates(scanned, min_size_bytes=min_kb * 1024)
151-
ui_groups = [[str(p) for p in grp] for grp in groups]
152-
153-
app.duplicates_view.render(ui_groups)
154-
app.tabs.set("Duplicates")
179+
app.set_status("Finding duplicates...")
180+
app.topbar_view.show_loading(True)
155181

182+
def run_duplicates():
183+
min_kb = int(app.settings.duplicate_min_size_kb) if getattr(app, "settings", None) else 1
184+
scanned = app.last_scan or scan_folder(app.ui_state.root, include_subfolders=app.ui_state.include_subfolders)
185+
groups = find_duplicates(scanned, min_size_bytes=min_kb * 1024)
186+
ui_groups = [[str(p) for p in grp] for grp in groups]
187+
188+
app.after(0, lambda: finalize_duplicates(ui_groups))
156189

157-
# ==========================================
158-
# OTHER PAGES (CHUNKERS, FLATTENER, ETC.)
159-
# ==========================================
160-
# (Logic remains identical to original)
190+
def finalize_duplicates(ui_groups):
191+
app.duplicates_view.render(ui_groups)
192+
app.tabs.set("Duplicates")
193+
app.topbar_view.show_loading(False)
194+
app.set_status("Ready")
195+
196+
threading.Thread(target=run_duplicates, daemon=True).start()
161197

162198
def chunker_choose_folder(app) -> None:
163199
path = filedialog.askdirectory()
@@ -190,14 +226,22 @@ def chunker_apply(app) -> None:
190226
if not plan or not root: return
191227
if _ask_confirm(app, "Folder Splitter", f"Split this folder into {len(plan)} parts?"):
192228
app.set_status("Splitting folder...")
193-
app.update()
194-
success, errors = apply_chunks(root, plan)
195-
if errors:
196-
messagebox.showwarning("Partial Success", f"Moved: {success}\nErrors: {len(errors)}")
197-
else:
198-
messagebox.showinfo("Success", f"Successfully chunked folder!")
199-
app.chunker_page.render_preview([])
200-
app.chunker_plan = []
229+
app.topbar_view.show_loading(True)
230+
231+
def run_chunk():
232+
success, errors = apply_chunks(root, plan)
233+
app.after(0, lambda: finalize_chunk(success, errors))
234+
235+
def finalize_chunk(success, errors):
236+
app.topbar_view.show_loading(False)
237+
app.set_status("Ready")
238+
if errors:
239+
messagebox.showwarning("Partial Success", f"Moved: {success}\nErrors: {len(errors)}")
240+
else:
241+
messagebox.showinfo("Success", f"Successfully chunked folder!")
242+
app.chunker_page.render_preview([])
243+
244+
threading.Thread(target=run_chunk, daemon=True).start()
201245

202246
def flattener_choose_folder(app) -> None:
203247
path = filedialog.askdirectory()

src/bulkfolder/ui/app.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def __init__(self, master, logo_path):
7272

7373
class App(ctk.CTk):
7474
"""
75-
The main application window with a persistent bottom Terminal and Topbar toggle.
75+
The main application window with multithreaded operations support.
7676
"""
7777
def __init__(self):
7878
super().__init__()
@@ -137,7 +137,7 @@ def __init__(self):
137137
self.content_area.grid_rowconfigure(1, weight=1)
138138
self.content_area.grid_rowconfigure(2, weight=0)
139139

140-
# The Topbar now correctly handles the terminal toggle
140+
# The Topbar handles status and loading animations
141141
self.topbar_view = TopbarView(self.content_area, on_toggle_sidebar=self.toggle_terminal)
142142
self.topbar_view.grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8))
143143

@@ -150,6 +150,9 @@ def __init__(self):
150150
self.logs_view = LogsView(self.content_area, on_close=self.toggle_terminal)
151151
self.logs_view.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18))
152152
self._terminal_visible = True
153+
154+
# Link log view to state for global access
155+
self.ui_state.log_view = self.logs_view
153156

154157
self.pages: dict[str, ctk.CTkFrame] = {}
155158
self.pages["Organizer"] = self._build_page_organizer(self.page_container)
@@ -304,14 +307,14 @@ def toggle_terminal(self) -> None:
304307
self._terminal_visible = True
305308

306309
def set_status(self, s: str) -> None:
310+
"""Updates the status in the top bar."""
307311
self.topbar_view.set_status(s)
308312

309313
def log(self, s: str, level: str = "INFO") -> None:
310314
"""
311-
Global logging method.
315+
Global logging method. Thread-safe log through UIState.
312316
"""
313-
if hasattr(self, "logs_view"):
314-
self.logs_view.log(s, level=level)
317+
self.ui_state.log(s, level=level)
315318

316319
@staticmethod
317320
def human_bytes(n: int) -> str:

src/bulkfolder/ui/state.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from __future__ import annotations
22
from dataclasses import dataclass
33
from pathlib import Path
4+
from typing import Any, Optional
45

56
@dataclass
67
class UIState:
8+
"""
9+
Maintains the global state of the application UI and shared components.
10+
"""
711
root: Path | None = None
812
include_subfolders: bool = True
913
find_text: str = ""
@@ -16,4 +20,14 @@ class UIState:
1620
empty_folders_root: Path | None = None
1721
unzipper_root: Path | None = None
1822
pdf_root: Path | None = None
19-
chunker_root: Path | None = None
23+
chunker_root: Path | None = None
24+
25+
# Reference to the central log view component
26+
log_view: Optional[Any] = None
27+
28+
def log(self, message: str, level: str = "INFO") -> None:
29+
"""
30+
Helper method to send logs to the UI terminal if available.
31+
"""
32+
if self.log_view and hasattr(self.log_view, "log"):
33+
self.log_view.log(message, level)

0 commit comments

Comments
 (0)