pomera-ai-commander 1.1.1 → 1.2.2
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.
- package/LICENSE +21 -21
- package/README.md +105 -680
- package/bin/pomera-ai-commander.js +62 -62
- package/core/__init__.py +65 -65
- package/core/app_context.py +482 -482
- package/core/async_text_processor.py +421 -421
- package/core/backup_manager.py +655 -655
- package/core/backup_recovery_manager.py +1199 -1033
- package/core/content_hash_cache.py +508 -508
- package/core/context_menu.py +313 -313
- package/core/data_directory.py +549 -0
- package/core/data_validator.py +1066 -1066
- package/core/database_connection_manager.py +744 -744
- package/core/database_curl_settings_manager.py +608 -608
- package/core/database_promera_ai_settings_manager.py +446 -446
- package/core/database_schema.py +411 -411
- package/core/database_schema_manager.py +395 -395
- package/core/database_settings_manager.py +1507 -1507
- package/core/database_settings_manager_interface.py +456 -456
- package/core/dialog_manager.py +734 -734
- package/core/diff_utils.py +239 -0
- package/core/efficient_line_numbers.py +540 -510
- package/core/error_handler.py +746 -746
- package/core/error_service.py +431 -431
- package/core/event_consolidator.py +511 -511
- package/core/mcp/__init__.py +43 -43
- package/core/mcp/find_replace_diff.py +334 -0
- package/core/mcp/protocol.py +288 -288
- package/core/mcp/schema.py +251 -251
- package/core/mcp/server_stdio.py +299 -299
- package/core/mcp/tool_registry.py +2699 -2345
- package/core/memento.py +275 -0
- package/core/memory_efficient_text_widget.py +711 -711
- package/core/migration_manager.py +914 -914
- package/core/migration_test_suite.py +1085 -1085
- package/core/migration_validator.py +1143 -1143
- package/core/optimized_find_replace.py +714 -714
- package/core/optimized_pattern_engine.py +424 -424
- package/core/optimized_search_highlighter.py +552 -552
- package/core/performance_monitor.py +674 -674
- package/core/persistence_manager.py +712 -712
- package/core/progressive_stats_calculator.py +632 -632
- package/core/regex_pattern_cache.py +529 -529
- package/core/regex_pattern_library.py +350 -350
- package/core/search_operation_manager.py +434 -434
- package/core/settings_defaults_registry.py +1087 -1087
- package/core/settings_integrity_validator.py +1111 -1111
- package/core/settings_serializer.py +557 -557
- package/core/settings_validator.py +1823 -1823
- package/core/smart_stats_calculator.py +709 -709
- package/core/statistics_update_manager.py +619 -619
- package/core/stats_config_manager.py +858 -858
- package/core/streaming_text_handler.py +723 -723
- package/core/task_scheduler.py +596 -596
- package/core/update_pattern_library.py +168 -168
- package/core/visibility_monitor.py +596 -596
- package/core/widget_cache.py +498 -498
- package/mcp.json +51 -61
- package/migrate_data.py +127 -0
- package/package.json +64 -57
- package/pomera.py +7883 -7482
- package/pomera_mcp_server.py +183 -144
- package/requirements.txt +33 -0
- package/scripts/Dockerfile.alpine +43 -0
- package/scripts/Dockerfile.gui-test +54 -0
- package/scripts/Dockerfile.linux +43 -0
- package/scripts/Dockerfile.test-linux +80 -0
- package/scripts/Dockerfile.ubuntu +39 -0
- package/scripts/README.md +53 -0
- package/scripts/build-all.bat +113 -0
- package/scripts/build-docker.bat +53 -0
- package/scripts/build-docker.sh +55 -0
- package/scripts/build-optimized.bat +101 -0
- package/scripts/build.sh +78 -0
- package/scripts/docker-compose.test.yml +27 -0
- package/scripts/docker-compose.yml +32 -0
- package/scripts/postinstall.js +62 -0
- package/scripts/requirements-minimal.txt +33 -0
- package/scripts/test-linux-simple.bat +28 -0
- package/scripts/validate-release-workflow.py +450 -0
- package/tools/__init__.py +4 -4
- package/tools/ai_tools.py +2891 -2891
- package/tools/ascii_art_generator.py +352 -352
- package/tools/base64_tools.py +183 -183
- package/tools/base_tool.py +511 -511
- package/tools/case_tool.py +308 -308
- package/tools/column_tools.py +395 -395
- package/tools/cron_tool.py +884 -884
- package/tools/curl_history.py +600 -600
- package/tools/curl_processor.py +1207 -1207
- package/tools/curl_settings.py +502 -502
- package/tools/curl_tool.py +5467 -5467
- package/tools/diff_viewer.py +1817 -1072
- package/tools/email_extraction_tool.py +248 -248
- package/tools/email_header_analyzer.py +425 -425
- package/tools/extraction_tools.py +250 -250
- package/tools/find_replace.py +2289 -1750
- package/tools/folder_file_reporter.py +1463 -1463
- package/tools/folder_file_reporter_adapter.py +480 -480
- package/tools/generator_tools.py +1216 -1216
- package/tools/hash_generator.py +255 -255
- package/tools/html_tool.py +656 -656
- package/tools/jsonxml_tool.py +729 -729
- package/tools/line_tools.py +419 -419
- package/tools/markdown_tools.py +561 -561
- package/tools/mcp_widget.py +1417 -1417
- package/tools/notes_widget.py +978 -973
- package/tools/number_base_converter.py +372 -372
- package/tools/regex_extractor.py +571 -571
- package/tools/slug_generator.py +310 -310
- package/tools/sorter_tools.py +458 -458
- package/tools/string_escape_tool.py +392 -392
- package/tools/text_statistics_tool.py +365 -365
- package/tools/text_wrapper.py +430 -430
- package/tools/timestamp_converter.py +421 -421
- package/tools/tool_loader.py +710 -710
- package/tools/translator_tools.py +522 -522
- package/tools/url_link_extractor.py +261 -261
- package/tools/url_parser.py +204 -204
- package/tools/whitespace_tools.py +355 -355
- package/tools/word_frequency_counter.py +146 -146
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/app_context.cpython-313.pyc +0 -0
- package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
- package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
- package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
- package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/error_service.cpython-313.pyc +0 -0
- package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
- package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
- package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
- package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
- package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
- package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
- package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
- package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
- package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
- package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
- package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
- package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
|
@@ -1,1463 +1,1463 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Folder File Reporter Tool
|
|
3
|
-
|
|
4
|
-
Generates customizable reports of directory contents with flexible configuration
|
|
5
|
-
options for output formatting, selective information display, and recursive
|
|
6
|
-
directory traversal.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import tkinter as tk
|
|
10
|
-
from tkinter import ttk, filedialog, messagebox
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
from collections import namedtuple
|
|
14
|
-
from datetime import datetime
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# File information data structure
|
|
18
|
-
FileInfo = namedtuple('FileInfo', [
|
|
19
|
-
'full_path', # str: Complete path to file/folder
|
|
20
|
-
'name', # str: File/folder name
|
|
21
|
-
'size', # int: Size in bytes (0 for folders)
|
|
22
|
-
'modified_time', # float: Timestamp of last modification
|
|
23
|
-
'is_folder' # bool: True if folder, False if file
|
|
24
|
-
])
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class FolderFileReporter:
|
|
28
|
-
"""
|
|
29
|
-
A tool for generating detailed reports of folder contents with customizable
|
|
30
|
-
output formatting and filtering options.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
def __init__(self, parent, dialog_manager=None, input_text_widget=None, output_text_widget=None):
|
|
34
|
-
"""
|
|
35
|
-
Initialize the Folder File Reporter tool.
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
parent: Parent tkinter widget (tool window)
|
|
39
|
-
dialog_manager: Optional DialogManager instance for consistent dialogs
|
|
40
|
-
input_text_widget: Reference to main application's Input tab text widget
|
|
41
|
-
output_text_widget: Reference to main application's Output tab text widget
|
|
42
|
-
"""
|
|
43
|
-
self.parent = parent
|
|
44
|
-
self.dialog_manager = dialog_manager
|
|
45
|
-
self.input_text_widget = input_text_widget
|
|
46
|
-
self.output_text_widget = output_text_widget
|
|
47
|
-
|
|
48
|
-
# Settings file path
|
|
49
|
-
self.settings_file = "settings.json"
|
|
50
|
-
self.tool_key = "Folder File Reporter" # Key in tool_settings section
|
|
51
|
-
|
|
52
|
-
# Initialize UI variables
|
|
53
|
-
self.input_folder_path = tk.StringVar()
|
|
54
|
-
self.output_folder_path = tk.StringVar()
|
|
55
|
-
|
|
56
|
-
# Field selection variables
|
|
57
|
-
self.field_selections = {
|
|
58
|
-
'path': tk.BooleanVar(value=True),
|
|
59
|
-
'name': tk.BooleanVar(value=True),
|
|
60
|
-
'size': tk.BooleanVar(value=True),
|
|
61
|
-
'date_modified': tk.BooleanVar(value=True)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
# Configuration variables
|
|
65
|
-
self.separator = tk.StringVar(value=" | ")
|
|
66
|
-
self.folders_only = tk.BooleanVar(value=False)
|
|
67
|
-
self.recursion_mode = tk.StringVar(value="full")
|
|
68
|
-
self.recursion_depth = tk.IntVar(value=2)
|
|
69
|
-
self.size_format = tk.StringVar(value="human")
|
|
70
|
-
self.date_format = tk.StringVar(value="%Y-%m-%d %H:%M:%S")
|
|
71
|
-
|
|
72
|
-
# Load saved settings
|
|
73
|
-
self.load_settings()
|
|
74
|
-
|
|
75
|
-
# Create UI
|
|
76
|
-
self._create_ui()
|
|
77
|
-
|
|
78
|
-
def load_settings(self):
|
|
79
|
-
"""
|
|
80
|
-
Load settings from centralized settings.json file.
|
|
81
|
-
|
|
82
|
-
Loads user preferences including field selections, separator, recursion mode,
|
|
83
|
-
and last used folder paths. If the settings file doesn't exist or is invalid,
|
|
84
|
-
default values are used.
|
|
85
|
-
"""
|
|
86
|
-
try:
|
|
87
|
-
if os.path.exists(self.settings_file):
|
|
88
|
-
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
|
89
|
-
all_settings = json.load(f)
|
|
90
|
-
|
|
91
|
-
# Get tool settings from tool_settings section
|
|
92
|
-
tool_settings = all_settings.get("tool_settings", {})
|
|
93
|
-
settings = tool_settings.get(self.tool_key, {})
|
|
94
|
-
|
|
95
|
-
# Load field selections
|
|
96
|
-
if 'field_selections' in settings:
|
|
97
|
-
for field, value in settings['field_selections'].items():
|
|
98
|
-
if field in self.field_selections:
|
|
99
|
-
self.field_selections[field].set(value)
|
|
100
|
-
|
|
101
|
-
# Load separator
|
|
102
|
-
if 'separator' in settings:
|
|
103
|
-
self.separator.set(settings['separator'])
|
|
104
|
-
|
|
105
|
-
# Load folders only setting
|
|
106
|
-
if 'folders_only' in settings:
|
|
107
|
-
self.folders_only.set(settings['folders_only'])
|
|
108
|
-
|
|
109
|
-
# Load recursion settings
|
|
110
|
-
if 'recursion_mode' in settings:
|
|
111
|
-
self.recursion_mode.set(settings['recursion_mode'])
|
|
112
|
-
if 'recursion_depth' in settings:
|
|
113
|
-
self.recursion_depth.set(settings['recursion_depth'])
|
|
114
|
-
|
|
115
|
-
# Load size format
|
|
116
|
-
if 'size_format' in settings:
|
|
117
|
-
self.size_format.set(settings['size_format'])
|
|
118
|
-
|
|
119
|
-
# Load date format
|
|
120
|
-
if 'date_format' in settings:
|
|
121
|
-
self.date_format.set(settings['date_format'])
|
|
122
|
-
|
|
123
|
-
# Load last used folders
|
|
124
|
-
if 'last_input_folder' in settings:
|
|
125
|
-
self.input_folder_path.set(settings['last_input_folder'])
|
|
126
|
-
if 'last_output_folder' in settings:
|
|
127
|
-
self.output_folder_path.set(settings['last_output_folder'])
|
|
128
|
-
|
|
129
|
-
except (json.JSONDecodeError, IOError) as e:
|
|
130
|
-
# If settings file is corrupted or can't be read, use defaults
|
|
131
|
-
print(f"Warning: Could not load settings from {self.settings_file}: {e}")
|
|
132
|
-
print("Using default settings")
|
|
133
|
-
|
|
134
|
-
def save_settings(self):
|
|
135
|
-
"""
|
|
136
|
-
Save current settings to centralized settings.json file.
|
|
137
|
-
|
|
138
|
-
Persists user preferences including field selections, separator, recursion mode,
|
|
139
|
-
and last used folder paths for future sessions.
|
|
140
|
-
"""
|
|
141
|
-
try:
|
|
142
|
-
# Load existing settings file
|
|
143
|
-
all_settings = {}
|
|
144
|
-
if os.path.exists(self.settings_file):
|
|
145
|
-
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
|
146
|
-
all_settings = json.load(f)
|
|
147
|
-
|
|
148
|
-
# Ensure tool_settings section exists
|
|
149
|
-
if "tool_settings" not in all_settings:
|
|
150
|
-
all_settings["tool_settings"] = {}
|
|
151
|
-
|
|
152
|
-
# Update Folder File Reporter settings
|
|
153
|
-
all_settings["tool_settings"][self.tool_key] = {
|
|
154
|
-
'field_selections': {
|
|
155
|
-
field: var.get() for field, var in self.field_selections.items()
|
|
156
|
-
},
|
|
157
|
-
'separator': self.separator.get(),
|
|
158
|
-
'folders_only': self.folders_only.get(),
|
|
159
|
-
'recursion_mode': self.recursion_mode.get(),
|
|
160
|
-
'recursion_depth': self.recursion_depth.get(),
|
|
161
|
-
'size_format': self.size_format.get(),
|
|
162
|
-
'date_format': self.date_format.get(),
|
|
163
|
-
'last_input_folder': self.input_folder_path.get(),
|
|
164
|
-
'last_output_folder': self.output_folder_path.get()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
# Write settings to file
|
|
168
|
-
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
|
169
|
-
json.dump(all_settings, f, indent=4, ensure_ascii=False)
|
|
170
|
-
|
|
171
|
-
except IOError as e:
|
|
172
|
-
print(f"Warning: Could not save settings to {self.settings_file}: {e}")
|
|
173
|
-
|
|
174
|
-
def _create_ui(self):
|
|
175
|
-
"""
|
|
176
|
-
Create the main user interface.
|
|
177
|
-
|
|
178
|
-
Sets up the main frame structure with tabs at the top and options panel below.
|
|
179
|
-
"""
|
|
180
|
-
# Main container frame
|
|
181
|
-
main_frame = ttk.Frame(self.parent)
|
|
182
|
-
main_frame.pack(expand=True, fill='both', padx=5, pady=5)
|
|
183
|
-
|
|
184
|
-
# Create tab notebook at the top
|
|
185
|
-
self._create_tab_notebook(main_frame)
|
|
186
|
-
|
|
187
|
-
# Create options panel below tabs
|
|
188
|
-
self._create_options_panel_two_column(main_frame)
|
|
189
|
-
|
|
190
|
-
# Create Process button below options panel
|
|
191
|
-
self._create_process_button(main_frame)
|
|
192
|
-
|
|
193
|
-
def _create_tab_notebook(self, parent):
|
|
194
|
-
"""
|
|
195
|
-
Create Input and Output tabs with scrolled text widgets.
|
|
196
|
-
|
|
197
|
-
Args:
|
|
198
|
-
parent: Parent frame to contain the notebook
|
|
199
|
-
"""
|
|
200
|
-
# Create notebook widget
|
|
201
|
-
self.notebook = ttk.Notebook(parent)
|
|
202
|
-
self.notebook.pack(expand=True, fill='both', pady=(0, 10))
|
|
203
|
-
|
|
204
|
-
# Create Input tab
|
|
205
|
-
input_frame = ttk.Frame(self.notebook)
|
|
206
|
-
self.notebook.add(input_frame, text="Input")
|
|
207
|
-
|
|
208
|
-
# Create scrolled text widget for Input tab
|
|
209
|
-
self.input_text = tk.Text(
|
|
210
|
-
input_frame,
|
|
211
|
-
wrap='none',
|
|
212
|
-
width=80,
|
|
213
|
-
height=15,
|
|
214
|
-
font=('Courier', 9)
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
# Add scrollbars for Input text widget
|
|
218
|
-
input_v_scrollbar = ttk.Scrollbar(input_frame, orient='vertical', command=self.input_text.yview)
|
|
219
|
-
input_h_scrollbar = ttk.Scrollbar(input_frame, orient='horizontal', command=self.input_text.xview)
|
|
220
|
-
self.input_text.configure(yscrollcommand=input_v_scrollbar.set, xscrollcommand=input_h_scrollbar.set)
|
|
221
|
-
|
|
222
|
-
# Grid layout for Input tab
|
|
223
|
-
self.input_text.grid(row=0, column=0, sticky='nsew')
|
|
224
|
-
input_v_scrollbar.grid(row=0, column=1, sticky='ns')
|
|
225
|
-
input_h_scrollbar.grid(row=1, column=0, sticky='ew')
|
|
226
|
-
|
|
227
|
-
input_frame.grid_rowconfigure(0, weight=1)
|
|
228
|
-
input_frame.grid_columnconfigure(0, weight=1)
|
|
229
|
-
|
|
230
|
-
# Create Output tab
|
|
231
|
-
output_frame = ttk.Frame(self.notebook)
|
|
232
|
-
self.notebook.add(output_frame, text="Output")
|
|
233
|
-
|
|
234
|
-
# Create scrolled text widget for Output tab
|
|
235
|
-
self.output_text = tk.Text(
|
|
236
|
-
output_frame,
|
|
237
|
-
wrap='none',
|
|
238
|
-
width=80,
|
|
239
|
-
height=15,
|
|
240
|
-
font=('Courier', 9)
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
# Add scrollbars for Output text widget
|
|
244
|
-
output_v_scrollbar = ttk.Scrollbar(output_frame, orient='vertical', command=self.output_text.yview)
|
|
245
|
-
output_h_scrollbar = ttk.Scrollbar(output_frame, orient='horizontal', command=self.output_text.xview)
|
|
246
|
-
self.output_text.configure(yscrollcommand=output_v_scrollbar.set, xscrollcommand=output_h_scrollbar.set)
|
|
247
|
-
|
|
248
|
-
# Grid layout for Output tab
|
|
249
|
-
self.output_text.grid(row=0, column=0, sticky='nsew')
|
|
250
|
-
output_v_scrollbar.grid(row=0, column=1, sticky='ns')
|
|
251
|
-
output_h_scrollbar.grid(row=1, column=0, sticky='ew')
|
|
252
|
-
|
|
253
|
-
output_frame.grid_rowconfigure(0, weight=1)
|
|
254
|
-
output_frame.grid_columnconfigure(0, weight=1)
|
|
255
|
-
|
|
256
|
-
# Add context menu support if available
|
|
257
|
-
try:
|
|
258
|
-
from core.context_menu import add_context_menu
|
|
259
|
-
self.input_text._context_menu = add_context_menu(self.input_text)
|
|
260
|
-
self.output_text._context_menu = add_context_menu(self.output_text)
|
|
261
|
-
except ImportError:
|
|
262
|
-
# Context menu module not available, skip
|
|
263
|
-
pass
|
|
264
|
-
|
|
265
|
-
def _create_options_panel_two_column(self, parent):
|
|
266
|
-
"""
|
|
267
|
-
Create options panel with two-column layout.
|
|
268
|
-
|
|
269
|
-
Left column: Input folder, field selections, separator, folders only
|
|
270
|
-
Right column: Output folder, recursion mode, size format
|
|
271
|
-
|
|
272
|
-
Args:
|
|
273
|
-
parent: Parent frame to contain the options panel
|
|
274
|
-
"""
|
|
275
|
-
# Options panel container
|
|
276
|
-
options_frame = ttk.LabelFrame(parent, text="Options", padding=10)
|
|
277
|
-
options_frame.pack(fill='x', pady=(0, 10))
|
|
278
|
-
|
|
279
|
-
# Create two-column layout
|
|
280
|
-
left_column = ttk.Frame(options_frame)
|
|
281
|
-
left_column.grid(row=0, column=0, sticky='nsew', padx=(0, 10))
|
|
282
|
-
|
|
283
|
-
right_column = ttk.Frame(options_frame)
|
|
284
|
-
right_column.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
|
|
285
|
-
|
|
286
|
-
# Configure grid weights for equal column widths
|
|
287
|
-
options_frame.grid_columnconfigure(0, weight=1)
|
|
288
|
-
options_frame.grid_columnconfigure(1, weight=1)
|
|
289
|
-
|
|
290
|
-
# Store references for later use in other tasks
|
|
291
|
-
self.left_column = left_column
|
|
292
|
-
self.right_column = right_column
|
|
293
|
-
|
|
294
|
-
# Populate columns with controls
|
|
295
|
-
self._create_folder_selection_controls()
|
|
296
|
-
self._create_field_selection_checkboxes()
|
|
297
|
-
self._create_separator_and_filter_controls()
|
|
298
|
-
self._create_recursion_mode_controls()
|
|
299
|
-
self._create_size_format_controls()
|
|
300
|
-
|
|
301
|
-
def _create_folder_selection_controls(self):
|
|
302
|
-
"""
|
|
303
|
-
Create folder picker UI elements in left and right columns.
|
|
304
|
-
|
|
305
|
-
Left column: Input folder selection
|
|
306
|
-
Right column: Output folder selection
|
|
307
|
-
Each includes a label, entry widget displaying the path, and Browse button.
|
|
308
|
-
"""
|
|
309
|
-
# LEFT COLUMN - Input Folder Selection
|
|
310
|
-
input_folder_label = ttk.Label(self.left_column, text="Input Folder:", font=('TkDefaultFont', 9, 'bold'))
|
|
311
|
-
input_folder_label.grid(row=0, column=0, sticky='w', pady=(0, 5))
|
|
312
|
-
|
|
313
|
-
# Entry widget to display selected input folder path
|
|
314
|
-
self.input_folder_entry = ttk.Entry(
|
|
315
|
-
self.left_column,
|
|
316
|
-
textvariable=self.input_folder_path,
|
|
317
|
-
width=40,
|
|
318
|
-
state='readonly'
|
|
319
|
-
)
|
|
320
|
-
self.input_folder_entry.grid(row=1, column=0, sticky='ew', pady=(0, 5))
|
|
321
|
-
|
|
322
|
-
# Browse button for input folder
|
|
323
|
-
input_browse_btn = ttk.Button(
|
|
324
|
-
self.left_column,
|
|
325
|
-
text="Browse...",
|
|
326
|
-
command=self._browse_input_folder
|
|
327
|
-
)
|
|
328
|
-
input_browse_btn.grid(row=2, column=0, sticky='w', pady=(0, 15))
|
|
329
|
-
|
|
330
|
-
# Configure column weight for entry widget expansion
|
|
331
|
-
self.left_column.grid_columnconfigure(0, weight=1)
|
|
332
|
-
|
|
333
|
-
# RIGHT COLUMN - Output Folder Selection
|
|
334
|
-
output_folder_label = ttk.Label(self.right_column, text="Output Folder:", font=('TkDefaultFont', 9, 'bold'))
|
|
335
|
-
output_folder_label.grid(row=0, column=0, sticky='w', pady=(0, 5))
|
|
336
|
-
|
|
337
|
-
# Entry widget to display selected output folder path
|
|
338
|
-
self.output_folder_entry = ttk.Entry(
|
|
339
|
-
self.right_column,
|
|
340
|
-
textvariable=self.output_folder_path,
|
|
341
|
-
width=40,
|
|
342
|
-
state='readonly'
|
|
343
|
-
)
|
|
344
|
-
self.output_folder_entry.grid(row=1, column=0, sticky='ew', pady=(0, 5))
|
|
345
|
-
|
|
346
|
-
# Browse button for output folder
|
|
347
|
-
output_browse_btn = ttk.Button(
|
|
348
|
-
self.right_column,
|
|
349
|
-
text="Browse...",
|
|
350
|
-
command=self._browse_output_folder
|
|
351
|
-
)
|
|
352
|
-
output_browse_btn.grid(row=2, column=0, sticky='w', pady=(0, 15))
|
|
353
|
-
|
|
354
|
-
# Configure column weight for entry widget expansion
|
|
355
|
-
self.right_column.grid_columnconfigure(0, weight=1)
|
|
356
|
-
|
|
357
|
-
def _browse_input_folder(self):
|
|
358
|
-
"""
|
|
359
|
-
Open native folder selection dialog for Input folder.
|
|
360
|
-
|
|
361
|
-
Uses filedialog.askdirectory() to allow user to select a folder.
|
|
362
|
-
Updates the input_folder_path StringVar with the selected path.
|
|
363
|
-
Saves settings after selection.
|
|
364
|
-
"""
|
|
365
|
-
# Get initial directory from current selection or user's home directory
|
|
366
|
-
initial_dir = self.input_folder_path.get() or os.path.expanduser("~")
|
|
367
|
-
|
|
368
|
-
# Open folder selection dialog
|
|
369
|
-
selected_folder = filedialog.askdirectory(
|
|
370
|
-
title="Select Input Folder",
|
|
371
|
-
initialdir=initial_dir if os.path.exists(initial_dir) else os.path.expanduser("~"),
|
|
372
|
-
parent=self.parent
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
# Update path if user selected a folder (not cancelled)
|
|
376
|
-
if selected_folder:
|
|
377
|
-
self.input_folder_path.set(selected_folder)
|
|
378
|
-
self.save_settings()
|
|
379
|
-
|
|
380
|
-
def _browse_output_folder(self):
|
|
381
|
-
"""
|
|
382
|
-
Open native folder selection dialog for Output folder.
|
|
383
|
-
|
|
384
|
-
Uses filedialog.askdirectory() to allow user to select a folder.
|
|
385
|
-
Updates the output_folder_path StringVar with the selected path.
|
|
386
|
-
Saves settings after selection.
|
|
387
|
-
"""
|
|
388
|
-
# Get initial directory from current selection or user's home directory
|
|
389
|
-
initial_dir = self.output_folder_path.get() or os.path.expanduser("~")
|
|
390
|
-
|
|
391
|
-
# Open folder selection dialog
|
|
392
|
-
selected_folder = filedialog.askdirectory(
|
|
393
|
-
title="Select Output Folder",
|
|
394
|
-
initialdir=initial_dir if os.path.exists(initial_dir) else os.path.expanduser("~"),
|
|
395
|
-
parent=self.parent
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
# Update path if user selected a folder (not cancelled)
|
|
399
|
-
if selected_folder:
|
|
400
|
-
self.output_folder_path.set(selected_folder)
|
|
401
|
-
self.save_settings()
|
|
402
|
-
|
|
403
|
-
def _create_field_selection_checkboxes(self):
|
|
404
|
-
"""
|
|
405
|
-
Create information field selection checkboxes in the left column.
|
|
406
|
-
|
|
407
|
-
Creates checkboxes for Path, File Name, Size, and Date Modified.
|
|
408
|
-
Each checkbox is bound to a BooleanVar and saves settings when changed.
|
|
409
|
-
Checkbox states are loaded from settings on initialization.
|
|
410
|
-
"""
|
|
411
|
-
# Information Fields section label
|
|
412
|
-
fields_label = ttk.Label(
|
|
413
|
-
self.left_column,
|
|
414
|
-
text="Information Fields:",
|
|
415
|
-
font=('TkDefaultFont', 9, 'bold')
|
|
416
|
-
)
|
|
417
|
-
fields_label.grid(row=3, column=0, sticky='w', pady=(0, 5))
|
|
418
|
-
|
|
419
|
-
# Create frame to contain checkboxes
|
|
420
|
-
fields_frame = ttk.Frame(self.left_column)
|
|
421
|
-
fields_frame.grid(row=4, column=0, sticky='w', pady=(0, 15))
|
|
422
|
-
|
|
423
|
-
# Create checkboxes for each field
|
|
424
|
-
# Path checkbox
|
|
425
|
-
path_checkbox = ttk.Checkbutton(
|
|
426
|
-
fields_frame,
|
|
427
|
-
text="Path",
|
|
428
|
-
variable=self.field_selections['path'],
|
|
429
|
-
command=self._on_field_selection_changed
|
|
430
|
-
)
|
|
431
|
-
path_checkbox.grid(row=0, column=0, sticky='w', pady=2)
|
|
432
|
-
|
|
433
|
-
# File Name checkbox
|
|
434
|
-
name_checkbox = ttk.Checkbutton(
|
|
435
|
-
fields_frame,
|
|
436
|
-
text="File Name",
|
|
437
|
-
variable=self.field_selections['name'],
|
|
438
|
-
command=self._on_field_selection_changed
|
|
439
|
-
)
|
|
440
|
-
name_checkbox.grid(row=1, column=0, sticky='w', pady=2)
|
|
441
|
-
|
|
442
|
-
# Size checkbox
|
|
443
|
-
size_checkbox = ttk.Checkbutton(
|
|
444
|
-
fields_frame,
|
|
445
|
-
text="Size",
|
|
446
|
-
variable=self.field_selections['size'],
|
|
447
|
-
command=self._on_field_selection_changed
|
|
448
|
-
)
|
|
449
|
-
size_checkbox.grid(row=2, column=0, sticky='w', pady=2)
|
|
450
|
-
|
|
451
|
-
# Date Modified checkbox
|
|
452
|
-
date_checkbox = ttk.Checkbutton(
|
|
453
|
-
fields_frame,
|
|
454
|
-
text="Date Modified",
|
|
455
|
-
variable=self.field_selections['date_modified'],
|
|
456
|
-
command=self._on_field_selection_changed
|
|
457
|
-
)
|
|
458
|
-
date_checkbox.grid(row=3, column=0, sticky='w', pady=2)
|
|
459
|
-
|
|
460
|
-
def _on_field_selection_changed(self):
|
|
461
|
-
"""
|
|
462
|
-
Callback when any field selection checkbox is changed.
|
|
463
|
-
|
|
464
|
-
Saves the current settings to persist the user's field selections.
|
|
465
|
-
"""
|
|
466
|
-
self.save_settings()
|
|
467
|
-
|
|
468
|
-
def _create_separator_and_filter_controls(self):
|
|
469
|
-
"""
|
|
470
|
-
Create separator configuration and file type filtering controls in the left column.
|
|
471
|
-
|
|
472
|
-
Creates:
|
|
473
|
-
- Separator text entry field with default value " | "
|
|
474
|
-
- "Folders Only" checkbox for file type filtering
|
|
475
|
-
|
|
476
|
-
The separator field supports escape sequences (\t, \n, \\) which are processed
|
|
477
|
-
when generating reports.
|
|
478
|
-
"""
|
|
479
|
-
# Separator configuration section
|
|
480
|
-
separator_label = ttk.Label(
|
|
481
|
-
self.left_column,
|
|
482
|
-
text="Separator:",
|
|
483
|
-
font=('TkDefaultFont', 9, 'bold')
|
|
484
|
-
)
|
|
485
|
-
separator_label.grid(row=5, column=0, sticky='w', pady=(0, 5))
|
|
486
|
-
|
|
487
|
-
# Create frame for separator entry and hint
|
|
488
|
-
separator_frame = ttk.Frame(self.left_column)
|
|
489
|
-
separator_frame.grid(row=6, column=0, sticky='ew', pady=(0, 5))
|
|
490
|
-
|
|
491
|
-
# Separator entry field
|
|
492
|
-
self.separator_entry = ttk.Entry(
|
|
493
|
-
separator_frame,
|
|
494
|
-
textvariable=self.separator,
|
|
495
|
-
width=20
|
|
496
|
-
)
|
|
497
|
-
self.separator_entry.pack(side='left', padx=(0, 5))
|
|
498
|
-
|
|
499
|
-
# Hint label for escape sequences
|
|
500
|
-
separator_hint = ttk.Label(
|
|
501
|
-
separator_frame,
|
|
502
|
-
text="(Use \\t for tab, \\n for newline)",
|
|
503
|
-
font=('TkDefaultFont', 8),
|
|
504
|
-
foreground='gray'
|
|
505
|
-
)
|
|
506
|
-
separator_hint.pack(side='left')
|
|
507
|
-
|
|
508
|
-
# Bind change event to save settings
|
|
509
|
-
self.separator.trace_add('write', lambda *args: self.save_settings())
|
|
510
|
-
|
|
511
|
-
# Folders Only checkbox
|
|
512
|
-
folders_only_checkbox = ttk.Checkbutton(
|
|
513
|
-
self.left_column,
|
|
514
|
-
text="Folders Only",
|
|
515
|
-
variable=self.folders_only,
|
|
516
|
-
command=self._on_folders_only_changed
|
|
517
|
-
)
|
|
518
|
-
folders_only_checkbox.grid(row=7, column=0, sticky='w', pady=(10, 0))
|
|
519
|
-
|
|
520
|
-
def _on_folders_only_changed(self):
|
|
521
|
-
"""
|
|
522
|
-
Callback when the Folders Only checkbox is changed.
|
|
523
|
-
|
|
524
|
-
Saves the current settings to persist the user's file type filter preference.
|
|
525
|
-
"""
|
|
526
|
-
self.save_settings()
|
|
527
|
-
|
|
528
|
-
def _create_recursion_mode_controls(self):
|
|
529
|
-
"""
|
|
530
|
-
Create recursion mode controls in the right column.
|
|
531
|
-
|
|
532
|
-
Creates:
|
|
533
|
-
- Radio buttons for "None", "Limited", and "Full" recursion modes
|
|
534
|
-
- Spinbox for depth value (visible only when "Limited" is selected)
|
|
535
|
-
|
|
536
|
-
The controls are bound to recursion_mode (StringVar) and recursion_depth (IntVar).
|
|
537
|
-
Settings are saved when values change.
|
|
538
|
-
"""
|
|
539
|
-
# Recursion section label
|
|
540
|
-
recursion_label = ttk.Label(
|
|
541
|
-
self.right_column,
|
|
542
|
-
text="Recursion:",
|
|
543
|
-
font=('TkDefaultFont', 9, 'bold')
|
|
544
|
-
)
|
|
545
|
-
recursion_label.grid(row=3, column=0, sticky='w', pady=(0, 5))
|
|
546
|
-
|
|
547
|
-
# Create frame to contain radio buttons and depth control
|
|
548
|
-
recursion_frame = ttk.Frame(self.right_column)
|
|
549
|
-
recursion_frame.grid(row=4, column=0, sticky='w', pady=(0, 15))
|
|
550
|
-
|
|
551
|
-
# Radio button for "None" mode
|
|
552
|
-
none_radio = ttk.Radiobutton(
|
|
553
|
-
recursion_frame,
|
|
554
|
-
text="None",
|
|
555
|
-
variable=self.recursion_mode,
|
|
556
|
-
value="none",
|
|
557
|
-
command=self._on_recursion_mode_changed
|
|
558
|
-
)
|
|
559
|
-
none_radio.grid(row=0, column=0, sticky='w', pady=2)
|
|
560
|
-
|
|
561
|
-
# Radio button for "Limited" mode
|
|
562
|
-
limited_radio = ttk.Radiobutton(
|
|
563
|
-
recursion_frame,
|
|
564
|
-
text="Limited",
|
|
565
|
-
variable=self.recursion_mode,
|
|
566
|
-
value="limited",
|
|
567
|
-
command=self._on_recursion_mode_changed
|
|
568
|
-
)
|
|
569
|
-
limited_radio.grid(row=1, column=0, sticky='w', pady=2)
|
|
570
|
-
|
|
571
|
-
# Depth spinbox (shown only when Limited is selected)
|
|
572
|
-
depth_frame = ttk.Frame(recursion_frame)
|
|
573
|
-
depth_frame.grid(row=1, column=1, sticky='w', padx=(5, 0))
|
|
574
|
-
|
|
575
|
-
ttk.Label(depth_frame, text="Depth:").pack(side='left', padx=(0, 5))
|
|
576
|
-
|
|
577
|
-
self.depth_spinbox = ttk.Spinbox(
|
|
578
|
-
depth_frame,
|
|
579
|
-
from_=1,
|
|
580
|
-
to=20,
|
|
581
|
-
width=5,
|
|
582
|
-
textvariable=self.recursion_depth,
|
|
583
|
-
command=self._on_recursion_depth_changed
|
|
584
|
-
)
|
|
585
|
-
self.depth_spinbox.pack(side='left')
|
|
586
|
-
|
|
587
|
-
# Bind spinbox entry changes as well
|
|
588
|
-
self.recursion_depth.trace_add('write', lambda *args: self._on_recursion_depth_changed())
|
|
589
|
-
|
|
590
|
-
# Radio button for "Full" mode
|
|
591
|
-
full_radio = ttk.Radiobutton(
|
|
592
|
-
recursion_frame,
|
|
593
|
-
text="Full",
|
|
594
|
-
variable=self.recursion_mode,
|
|
595
|
-
value="full",
|
|
596
|
-
command=self._on_recursion_mode_changed
|
|
597
|
-
)
|
|
598
|
-
full_radio.grid(row=2, column=0, sticky='w', pady=2)
|
|
599
|
-
|
|
600
|
-
# Update depth spinbox visibility based on initial mode
|
|
601
|
-
self._update_depth_spinbox_visibility()
|
|
602
|
-
|
|
603
|
-
def _on_recursion_mode_changed(self):
|
|
604
|
-
"""
|
|
605
|
-
Callback when recursion mode radio button is changed.
|
|
606
|
-
|
|
607
|
-
Updates the visibility of the depth spinbox (only visible for "Limited" mode)
|
|
608
|
-
and saves the current settings.
|
|
609
|
-
"""
|
|
610
|
-
self._update_depth_spinbox_visibility()
|
|
611
|
-
self.save_settings()
|
|
612
|
-
|
|
613
|
-
def _on_recursion_depth_changed(self):
|
|
614
|
-
"""
|
|
615
|
-
Callback when recursion depth value is changed.
|
|
616
|
-
|
|
617
|
-
Saves the current settings to persist the user's depth preference.
|
|
618
|
-
"""
|
|
619
|
-
self.save_settings()
|
|
620
|
-
|
|
621
|
-
def _update_depth_spinbox_visibility(self):
|
|
622
|
-
"""
|
|
623
|
-
Update the visibility of the depth spinbox based on recursion mode.
|
|
624
|
-
|
|
625
|
-
The depth spinbox is only visible when "Limited" recursion mode is selected.
|
|
626
|
-
For "None" and "Full" modes, the spinbox is hidden.
|
|
627
|
-
"""
|
|
628
|
-
if self.recursion_mode.get() == "limited":
|
|
629
|
-
self.depth_spinbox.config(state='normal')
|
|
630
|
-
else:
|
|
631
|
-
self.depth_spinbox.config(state='disabled')
|
|
632
|
-
|
|
633
|
-
def _create_size_format_controls(self):
|
|
634
|
-
"""
|
|
635
|
-
Create size format selection controls in the right column.
|
|
636
|
-
|
|
637
|
-
Creates radio buttons for "Bytes" and "Human Readable" size formats.
|
|
638
|
-
The control is bound to the size_format StringVar.
|
|
639
|
-
Settings are saved when the value changes.
|
|
640
|
-
"""
|
|
641
|
-
# Size Format section label
|
|
642
|
-
size_format_label = ttk.Label(
|
|
643
|
-
self.right_column,
|
|
644
|
-
text="Size Format:",
|
|
645
|
-
font=('TkDefaultFont', 9, 'bold')
|
|
646
|
-
)
|
|
647
|
-
size_format_label.grid(row=5, column=0, sticky='w', pady=(0, 5))
|
|
648
|
-
|
|
649
|
-
# Create frame to contain radio buttons
|
|
650
|
-
size_format_frame = ttk.Frame(self.right_column)
|
|
651
|
-
size_format_frame.grid(row=6, column=0, sticky='w', pady=(0, 15))
|
|
652
|
-
|
|
653
|
-
# Radio button for "Bytes" format
|
|
654
|
-
bytes_radio = ttk.Radiobutton(
|
|
655
|
-
size_format_frame,
|
|
656
|
-
text="Bytes",
|
|
657
|
-
variable=self.size_format,
|
|
658
|
-
value="bytes",
|
|
659
|
-
command=self._on_size_format_changed
|
|
660
|
-
)
|
|
661
|
-
bytes_radio.grid(row=0, column=0, sticky='w', pady=2)
|
|
662
|
-
|
|
663
|
-
# Radio button for "Human Readable" format
|
|
664
|
-
human_radio = ttk.Radiobutton(
|
|
665
|
-
size_format_frame,
|
|
666
|
-
text="Human Readable",
|
|
667
|
-
variable=self.size_format,
|
|
668
|
-
value="human",
|
|
669
|
-
command=self._on_size_format_changed
|
|
670
|
-
)
|
|
671
|
-
human_radio.grid(row=1, column=0, sticky='w', pady=2)
|
|
672
|
-
|
|
673
|
-
def _on_size_format_changed(self):
|
|
674
|
-
"""
|
|
675
|
-
Callback when size format radio button is changed.
|
|
676
|
-
|
|
677
|
-
Saves the current settings to persist the user's size format preference.
|
|
678
|
-
"""
|
|
679
|
-
self.save_settings()
|
|
680
|
-
|
|
681
|
-
def _create_process_button(self, parent):
|
|
682
|
-
"""
|
|
683
|
-
Create the Process button below the options panel.
|
|
684
|
-
|
|
685
|
-
The Process button triggers report generation after validating that:
|
|
686
|
-
- At least one information field is selected
|
|
687
|
-
- At least one folder is selected
|
|
688
|
-
|
|
689
|
-
Args:
|
|
690
|
-
parent: Parent frame to contain the button
|
|
691
|
-
"""
|
|
692
|
-
# Create frame for button (centered)
|
|
693
|
-
button_frame = ttk.Frame(parent)
|
|
694
|
-
button_frame.pack(fill='x', pady=(0, 10))
|
|
695
|
-
|
|
696
|
-
# Create Process button
|
|
697
|
-
self.process_button = ttk.Button(
|
|
698
|
-
button_frame,
|
|
699
|
-
text="Process",
|
|
700
|
-
command=self._on_process_clicked,
|
|
701
|
-
width=20
|
|
702
|
-
)
|
|
703
|
-
self.process_button.pack(pady=5)
|
|
704
|
-
|
|
705
|
-
def _on_process_clicked(self):
|
|
706
|
-
"""
|
|
707
|
-
Handle Process button click event.
|
|
708
|
-
|
|
709
|
-
Validates user selections and initiates report generation if validation passes.
|
|
710
|
-
Displays warning messages if validation fails.
|
|
711
|
-
|
|
712
|
-
Validation checks:
|
|
713
|
-
1. At least one information field must be selected
|
|
714
|
-
2. At least one folder must be selected
|
|
715
|
-
|
|
716
|
-
Requirements: 1.4, 2.3
|
|
717
|
-
"""
|
|
718
|
-
# Validate that at least one field is selected
|
|
719
|
-
if not self._validate_field_selection():
|
|
720
|
-
self._show_warning(
|
|
721
|
-
"No Fields Selected",
|
|
722
|
-
"Please select at least one information field to include in the report."
|
|
723
|
-
)
|
|
724
|
-
return
|
|
725
|
-
|
|
726
|
-
# Validate that at least one folder is selected
|
|
727
|
-
if not self._validate_folder_selection():
|
|
728
|
-
self._show_warning(
|
|
729
|
-
"No Folders Selected",
|
|
730
|
-
"Please select at least one folder (Input or Output) to generate a report."
|
|
731
|
-
)
|
|
732
|
-
return
|
|
733
|
-
|
|
734
|
-
# Validation passed - proceed with report generation
|
|
735
|
-
self.generate_report()
|
|
736
|
-
|
|
737
|
-
def _validate_field_selection(self):
|
|
738
|
-
"""
|
|
739
|
-
Validate that at least one information field is selected.
|
|
740
|
-
|
|
741
|
-
Returns:
|
|
742
|
-
bool: True if at least one field is selected, False otherwise
|
|
743
|
-
"""
|
|
744
|
-
# Check if any field checkbox is checked
|
|
745
|
-
for field_var in self.field_selections.values():
|
|
746
|
-
if field_var.get():
|
|
747
|
-
return True
|
|
748
|
-
return False
|
|
749
|
-
|
|
750
|
-
def _validate_folder_selection(self):
|
|
751
|
-
"""
|
|
752
|
-
Validate that at least one folder is selected.
|
|
753
|
-
|
|
754
|
-
Returns:
|
|
755
|
-
bool: True if at least one folder path is set, False otherwise
|
|
756
|
-
"""
|
|
757
|
-
# Check if either input or output folder is selected
|
|
758
|
-
input_folder = self.input_folder_path.get().strip()
|
|
759
|
-
output_folder = self.output_folder_path.get().strip()
|
|
760
|
-
|
|
761
|
-
return bool(input_folder or output_folder)
|
|
762
|
-
|
|
763
|
-
def generate_report(self):
|
|
764
|
-
"""
|
|
765
|
-
Generate reports for selected folders and display results in respective tabs.
|
|
766
|
-
|
|
767
|
-
Orchestrates the scanning and formatting process:
|
|
768
|
-
1. Processes Input folder and writes results to Input tab text widget
|
|
769
|
-
2. Processes Output folder and writes results to Output tab text widget
|
|
770
|
-
3. Handles case where only one folder is selected
|
|
771
|
-
4. Displays each item on a separate line
|
|
772
|
-
5. Adds summary line with total item count at the end
|
|
773
|
-
6. Clears existing text before inserting new report
|
|
774
|
-
|
|
775
|
-
Requirements: 1.4, 1.5, 6.1, 6.2, 6.3, 6.4, 6.5
|
|
776
|
-
"""
|
|
777
|
-
# Get folder paths
|
|
778
|
-
input_folder = self.input_folder_path.get().strip()
|
|
779
|
-
output_folder = self.output_folder_path.get().strip()
|
|
780
|
-
|
|
781
|
-
# Process Input folder if selected
|
|
782
|
-
if input_folder:
|
|
783
|
-
self._generate_report_for_folder(input_folder, self.input_text, "Input")
|
|
784
|
-
|
|
785
|
-
# Process Output folder if selected
|
|
786
|
-
if output_folder:
|
|
787
|
-
self._generate_report_for_folder(output_folder, self.output_text, "Output")
|
|
788
|
-
|
|
789
|
-
def _generate_report_for_folder(self, folder_path, text_widget, tab_name):
|
|
790
|
-
"""
|
|
791
|
-
Generate report for a single folder and display in the specified text widget.
|
|
792
|
-
|
|
793
|
-
Args:
|
|
794
|
-
folder_path: Path to the folder to scan
|
|
795
|
-
text_widget: Text widget to display the report
|
|
796
|
-
tab_name: Name of the tab (for error messages)
|
|
797
|
-
|
|
798
|
-
Requirements: 6.1, 6.2, 6.4, 6.5, 7.5, 8.1, 8.2, 8.3, 8.4, 8.5
|
|
799
|
-
"""
|
|
800
|
-
# Normalize the folder path to use consistent separators
|
|
801
|
-
folder_path = os.path.normpath(folder_path)
|
|
802
|
-
|
|
803
|
-
# Clear existing text before inserting new report
|
|
804
|
-
text_widget.delete('1.0', tk.END)
|
|
805
|
-
|
|
806
|
-
# Track errors encountered during processing
|
|
807
|
-
errors_encountered = []
|
|
808
|
-
|
|
809
|
-
# Validate folder exists (Requirement 8.1)
|
|
810
|
-
if not os.path.exists(folder_path):
|
|
811
|
-
error_msg = f"Error: Folder does not exist: {folder_path}"
|
|
812
|
-
text_widget.insert('1.0', error_msg)
|
|
813
|
-
self._show_error("Folder Not Found", f"The selected {tab_name} folder does not exist:\n{folder_path}")
|
|
814
|
-
self._log_error(f"Folder not found: {folder_path}")
|
|
815
|
-
return
|
|
816
|
-
|
|
817
|
-
# Validate it's a directory
|
|
818
|
-
if not os.path.isdir(folder_path):
|
|
819
|
-
error_msg = f"Error: Path is not a directory: {folder_path}"
|
|
820
|
-
text_widget.insert('1.0', error_msg)
|
|
821
|
-
self._show_error("Invalid Path", f"The selected {tab_name} path is not a directory:\n{folder_path}")
|
|
822
|
-
self._log_error(f"Invalid path (not a directory): {folder_path}")
|
|
823
|
-
return
|
|
824
|
-
|
|
825
|
-
# Check permissions (Requirement 8.2)
|
|
826
|
-
if not os.access(folder_path, os.R_OK):
|
|
827
|
-
error_msg = f"Error: Permission denied accessing folder: {folder_path}"
|
|
828
|
-
text_widget.insert('1.0', error_msg)
|
|
829
|
-
self._show_error("Permission Denied", f"You do not have permission to access the {tab_name} folder:\n{folder_path}")
|
|
830
|
-
self._log_error(f"Permission denied: {folder_path}")
|
|
831
|
-
return
|
|
832
|
-
|
|
833
|
-
try:
|
|
834
|
-
# Show progress indicator for large directories (Requirement 8.3)
|
|
835
|
-
text_widget.insert('1.0', f"Scanning {tab_name} folder...\nPlease wait...\n")
|
|
836
|
-
text_widget.update_idletasks()
|
|
837
|
-
|
|
838
|
-
# Initialize progress tracking
|
|
839
|
-
progress_info = {
|
|
840
|
-
'count': 0,
|
|
841
|
-
'text_widget': text_widget,
|
|
842
|
-
'tab_name': tab_name,
|
|
843
|
-
'last_update': 0
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
# Scan directory to collect file information
|
|
847
|
-
# Pass errors_encountered list to collect errors during scan
|
|
848
|
-
# Pass progress_info to enable progress tracking
|
|
849
|
-
items = self._scan_directory(folder_path, errors_list=errors_encountered, progress_info=progress_info)
|
|
850
|
-
|
|
851
|
-
# Clear the progress message
|
|
852
|
-
text_widget.delete('1.0', tk.END)
|
|
853
|
-
|
|
854
|
-
# Format and display each item on a separate line
|
|
855
|
-
report_lines = []
|
|
856
|
-
for item in items:
|
|
857
|
-
try:
|
|
858
|
-
# Format the report line for this item
|
|
859
|
-
line = self._format_report_line(item)
|
|
860
|
-
report_lines.append(line)
|
|
861
|
-
except Exception as e:
|
|
862
|
-
# Log formatting error but continue processing (Requirement 8.5)
|
|
863
|
-
error_msg = f"Error formatting item {item.full_path}: {e}"
|
|
864
|
-
errors_encountered.append(error_msg)
|
|
865
|
-
self._log_error(error_msg)
|
|
866
|
-
|
|
867
|
-
# Join all lines with newlines
|
|
868
|
-
report_text = '\n'.join(report_lines)
|
|
869
|
-
|
|
870
|
-
# Add error summary if errors were encountered (Requirement 8.5)
|
|
871
|
-
if errors_encountered:
|
|
872
|
-
error_summary = f"\n\n--- Errors Encountered ({len(errors_encountered)}) ---\n"
|
|
873
|
-
error_summary += '\n'.join(f" • {err}" for err in errors_encountered[:10]) # Show first 10 errors
|
|
874
|
-
if len(errors_encountered) > 10:
|
|
875
|
-
error_summary += f"\n ... and {len(errors_encountered) - 10} more errors"
|
|
876
|
-
report_text += error_summary
|
|
877
|
-
|
|
878
|
-
# Add summary line with total item count at the end (Requirement 8.4)
|
|
879
|
-
summary_line = f"\n\n--- Report Complete: {len(items):,} items processed ---"
|
|
880
|
-
report_text += summary_line
|
|
881
|
-
|
|
882
|
-
# Insert report into text widget
|
|
883
|
-
text_widget.insert('1.0', report_text)
|
|
884
|
-
|
|
885
|
-
# Display success message with item count (Requirement 8.4)
|
|
886
|
-
success_msg = f"{tab_name} folder report generated successfully.\n{len(items):,} items processed."
|
|
887
|
-
if errors_encountered:
|
|
888
|
-
success_msg += f"\n{len(errors_encountered)} errors encountered (see report for details)."
|
|
889
|
-
self._show_info("Report Generated", success_msg)
|
|
890
|
-
|
|
891
|
-
except PermissionError as e:
|
|
892
|
-
# Handle permission errors (Requirement 8.2)
|
|
893
|
-
error_msg = f"Error: Permission denied during scan: {e}"
|
|
894
|
-
text_widget.delete('1.0', tk.END)
|
|
895
|
-
text_widget.insert('1.0', error_msg)
|
|
896
|
-
self._show_error("Permission Error", f"Permission error while scanning {tab_name} folder:\n{e}")
|
|
897
|
-
self._log_error(f"Permission error during scan: {e}")
|
|
898
|
-
|
|
899
|
-
except FileNotFoundError as e:
|
|
900
|
-
# Handle file not found errors (Requirement 7.5)
|
|
901
|
-
error_msg = f"Error: File or folder not found during scan: {e}"
|
|
902
|
-
text_widget.delete('1.0', tk.END)
|
|
903
|
-
text_widget.insert('1.0', error_msg)
|
|
904
|
-
self._show_error("File Not Found", f"File or folder not found while scanning {tab_name} folder:\n{e}")
|
|
905
|
-
self._log_error(f"File not found during scan: {e}")
|
|
906
|
-
|
|
907
|
-
except OSError as e:
|
|
908
|
-
# Handle OS errors (Requirement 8.5)
|
|
909
|
-
error_msg = f"Error: OS error during scan: {e}"
|
|
910
|
-
text_widget.delete('1.0', tk.END)
|
|
911
|
-
text_widget.insert('1.0', error_msg)
|
|
912
|
-
self._show_error("File System Error", f"Error accessing {tab_name} folder:\n{e}")
|
|
913
|
-
self._log_error(f"OS error during scan: {e}")
|
|
914
|
-
|
|
915
|
-
except Exception as e:
|
|
916
|
-
# Handle unexpected errors (Requirement 8.5)
|
|
917
|
-
error_msg = f"Error: Unexpected error during scan: {e}"
|
|
918
|
-
text_widget.delete('1.0', tk.END)
|
|
919
|
-
text_widget.insert('1.0', error_msg)
|
|
920
|
-
self._show_error("Unexpected Error", f"An unexpected error occurred while processing {tab_name} folder:\n{e}")
|
|
921
|
-
self._log_error(f"Unexpected error during scan: {e}")
|
|
922
|
-
|
|
923
|
-
def _show_warning(self, title, message):
|
|
924
|
-
"""
|
|
925
|
-
Display a warning message to the user.
|
|
926
|
-
|
|
927
|
-
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
928
|
-
|
|
929
|
-
Args:
|
|
930
|
-
title: Warning dialog title
|
|
931
|
-
message: Warning message text
|
|
932
|
-
"""
|
|
933
|
-
if self.dialog_manager and hasattr(self.dialog_manager, 'show_warning'):
|
|
934
|
-
self.dialog_manager.show_warning(title, message, parent=self.parent)
|
|
935
|
-
else:
|
|
936
|
-
messagebox.showwarning(title, message, parent=self.parent)
|
|
937
|
-
|
|
938
|
-
def _show_info(self, title, message):
|
|
939
|
-
"""
|
|
940
|
-
Display an information message to the user.
|
|
941
|
-
|
|
942
|
-
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
943
|
-
|
|
944
|
-
Args:
|
|
945
|
-
title: Information dialog title
|
|
946
|
-
message: Information message text
|
|
947
|
-
"""
|
|
948
|
-
if self.dialog_manager and hasattr(self.dialog_manager, 'show_info'):
|
|
949
|
-
self.dialog_manager.show_info(title, message, parent=self.parent)
|
|
950
|
-
else:
|
|
951
|
-
messagebox.showinfo(title, message, parent=self.parent)
|
|
952
|
-
|
|
953
|
-
def _format_report_line(self, file_info):
|
|
954
|
-
"""
|
|
955
|
-
Format a single line of the report based on selected fields.
|
|
956
|
-
|
|
957
|
-
Builds output lines by including only the selected information fields
|
|
958
|
-
in the specified order: path, name, size, date_modified.
|
|
959
|
-
Processes separator string with escape sequences and formats values
|
|
960
|
-
according to user preferences.
|
|
961
|
-
|
|
962
|
-
Args:
|
|
963
|
-
file_info: FileInfo namedtuple containing file/folder information
|
|
964
|
-
|
|
965
|
-
Returns:
|
|
966
|
-
str: Formatted report line with selected fields separated by the configured separator
|
|
967
|
-
|
|
968
|
-
Requirements: 2.2, 2.4, 2.5, 3.2, 3.4, 3.5, 7.1, 7.2, 7.3, 7.4
|
|
969
|
-
"""
|
|
970
|
-
# Collect selected field values in the correct order
|
|
971
|
-
field_values = []
|
|
972
|
-
|
|
973
|
-
# Field ordering: path, name, size, date_modified
|
|
974
|
-
|
|
975
|
-
# 1. Path field
|
|
976
|
-
if self.field_selections['path'].get():
|
|
977
|
-
field_values.append(file_info.full_path)
|
|
978
|
-
|
|
979
|
-
# 2. Name field
|
|
980
|
-
if self.field_selections['name'].get():
|
|
981
|
-
field_values.append(file_info.name)
|
|
982
|
-
|
|
983
|
-
# 3. Size field
|
|
984
|
-
if self.field_selections['size'].get():
|
|
985
|
-
formatted_size = self._format_size(file_info.size, file_info.is_folder)
|
|
986
|
-
field_values.append(formatted_size)
|
|
987
|
-
|
|
988
|
-
# 4. Date Modified field
|
|
989
|
-
if self.field_selections['date_modified'].get():
|
|
990
|
-
formatted_date = self._format_date(file_info.modified_time)
|
|
991
|
-
field_values.append(formatted_date)
|
|
992
|
-
|
|
993
|
-
# Process separator string with escape sequences
|
|
994
|
-
separator = self._process_separator()
|
|
995
|
-
|
|
996
|
-
# Join field values with the separator
|
|
997
|
-
return separator.join(field_values)
|
|
998
|
-
|
|
999
|
-
def _format_size(self, size_bytes, is_folder):
|
|
1000
|
-
"""
|
|
1001
|
-
Format file size according to the selected size format.
|
|
1002
|
-
|
|
1003
|
-
Args:
|
|
1004
|
-
size_bytes: Size in bytes (int)
|
|
1005
|
-
is_folder: Whether the item is a folder (bool)
|
|
1006
|
-
|
|
1007
|
-
Returns:
|
|
1008
|
-
str: Formatted size string
|
|
1009
|
-
|
|
1010
|
-
Requirements: 7.3
|
|
1011
|
-
"""
|
|
1012
|
-
# Folders typically show 0 or a special indicator
|
|
1013
|
-
if is_folder:
|
|
1014
|
-
if self.size_format.get() == "bytes":
|
|
1015
|
-
return "0"
|
|
1016
|
-
else:
|
|
1017
|
-
return "<DIR>"
|
|
1018
|
-
|
|
1019
|
-
# Format based on selected size format
|
|
1020
|
-
if self.size_format.get() == "bytes":
|
|
1021
|
-
# Return size in bytes as a string
|
|
1022
|
-
return str(size_bytes)
|
|
1023
|
-
else:
|
|
1024
|
-
# Human-readable format (KB, MB, GB, TB)
|
|
1025
|
-
return self._format_size_human_readable(size_bytes)
|
|
1026
|
-
|
|
1027
|
-
def _format_size_human_readable(self, size_bytes):
|
|
1028
|
-
"""
|
|
1029
|
-
Format size in human-readable format with appropriate units.
|
|
1030
|
-
|
|
1031
|
-
Args:
|
|
1032
|
-
size_bytes: Size in bytes (int)
|
|
1033
|
-
|
|
1034
|
-
Returns:
|
|
1035
|
-
str: Human-readable size string (e.g., "1.18 MB", "523 KB")
|
|
1036
|
-
|
|
1037
|
-
Requirements: 7.3
|
|
1038
|
-
"""
|
|
1039
|
-
# Define size units and thresholds
|
|
1040
|
-
units = [
|
|
1041
|
-
('TB', 1024**4),
|
|
1042
|
-
('GB', 1024**3),
|
|
1043
|
-
('MB', 1024**2),
|
|
1044
|
-
('KB', 1024)
|
|
1045
|
-
]
|
|
1046
|
-
|
|
1047
|
-
# Handle zero or very small sizes
|
|
1048
|
-
if size_bytes == 0:
|
|
1049
|
-
return "0 bytes"
|
|
1050
|
-
|
|
1051
|
-
if size_bytes < 1024:
|
|
1052
|
-
return f"{size_bytes} bytes"
|
|
1053
|
-
|
|
1054
|
-
# Find appropriate unit
|
|
1055
|
-
for unit_name, unit_size in units:
|
|
1056
|
-
if size_bytes >= unit_size:
|
|
1057
|
-
size_value = size_bytes / unit_size
|
|
1058
|
-
# Format with 2 decimal places, but remove trailing zeros
|
|
1059
|
-
formatted = f"{size_value:.2f}".rstrip('0').rstrip('.')
|
|
1060
|
-
return f"{formatted} {unit_name}"
|
|
1061
|
-
|
|
1062
|
-
# Fallback (should not reach here)
|
|
1063
|
-
return f"{size_bytes} bytes"
|
|
1064
|
-
|
|
1065
|
-
def _format_date(self, timestamp):
|
|
1066
|
-
"""
|
|
1067
|
-
Format modification timestamp using the configured date format.
|
|
1068
|
-
|
|
1069
|
-
Args:
|
|
1070
|
-
timestamp: Unix timestamp (float)
|
|
1071
|
-
|
|
1072
|
-
Returns:
|
|
1073
|
-
str: Formatted date string
|
|
1074
|
-
|
|
1075
|
-
Requirements: 7.4
|
|
1076
|
-
"""
|
|
1077
|
-
try:
|
|
1078
|
-
# Convert timestamp to datetime object
|
|
1079
|
-
dt = datetime.fromtimestamp(timestamp)
|
|
1080
|
-
|
|
1081
|
-
# Format using the configured format string
|
|
1082
|
-
# Default format: "%Y-%m-%d %H:%M:%S" (YYYY-MM-DD HH:MM:SS)
|
|
1083
|
-
date_format = self.date_format.get()
|
|
1084
|
-
return dt.strftime(date_format)
|
|
1085
|
-
|
|
1086
|
-
except (ValueError, OSError) as e:
|
|
1087
|
-
# Handle invalid timestamps
|
|
1088
|
-
return "Invalid Date"
|
|
1089
|
-
|
|
1090
|
-
def _process_separator(self):
|
|
1091
|
-
"""
|
|
1092
|
-
Process separator string to interpret escape sequences.
|
|
1093
|
-
|
|
1094
|
-
Converts common escape sequences to their actual characters:
|
|
1095
|
-
- \\t → tab character
|
|
1096
|
-
- \\n → newline character
|
|
1097
|
-
- \\\\ → backslash character
|
|
1098
|
-
|
|
1099
|
-
Returns:
|
|
1100
|
-
str: Processed separator string with escape sequences converted
|
|
1101
|
-
|
|
1102
|
-
Requirements: 3.2, 3.4
|
|
1103
|
-
"""
|
|
1104
|
-
separator = self.separator.get()
|
|
1105
|
-
|
|
1106
|
-
# Process escape sequences
|
|
1107
|
-
# Note: We need to be careful with the order of replacements
|
|
1108
|
-
# to avoid double-processing
|
|
1109
|
-
|
|
1110
|
-
# First, handle double backslash (\\) by replacing with a placeholder
|
|
1111
|
-
separator = separator.replace('\\\\', '\x00')
|
|
1112
|
-
|
|
1113
|
-
# Then handle \t and \n
|
|
1114
|
-
separator = separator.replace('\\t', '\t')
|
|
1115
|
-
separator = separator.replace('\\n', '\n')
|
|
1116
|
-
|
|
1117
|
-
# Finally, restore the placeholder to a single backslash
|
|
1118
|
-
separator = separator.replace('\x00', '\\')
|
|
1119
|
-
|
|
1120
|
-
return separator
|
|
1121
|
-
|
|
1122
|
-
def _show_error(self, title, message):
|
|
1123
|
-
"""
|
|
1124
|
-
Display an error message to the user.
|
|
1125
|
-
|
|
1126
|
-
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
1127
|
-
|
|
1128
|
-
Args:
|
|
1129
|
-
title: Error dialog title
|
|
1130
|
-
message: Error message text
|
|
1131
|
-
|
|
1132
|
-
Requirements: 8.1, 8.2, 8.5
|
|
1133
|
-
"""
|
|
1134
|
-
if self.dialog_manager and hasattr(self.dialog_manager, 'show_error'):
|
|
1135
|
-
self.dialog_manager.show_error(title, message, parent=self.parent)
|
|
1136
|
-
else:
|
|
1137
|
-
messagebox.showerror(title, message, parent=self.parent)
|
|
1138
|
-
|
|
1139
|
-
def _log_error(self, error_message):
|
|
1140
|
-
"""
|
|
1141
|
-
Log error messages for debugging and troubleshooting.
|
|
1142
|
-
|
|
1143
|
-
Prints error messages to console. In a production environment,
|
|
1144
|
-
this could be extended to write to a log file.
|
|
1145
|
-
|
|
1146
|
-
Args:
|
|
1147
|
-
error_message: Error message to log
|
|
1148
|
-
|
|
1149
|
-
Requirements: 8.5
|
|
1150
|
-
"""
|
|
1151
|
-
print(f"[Folder File Reporter Error] {error_message}")
|
|
1152
|
-
|
|
1153
|
-
def _update_progress(self, progress_info):
|
|
1154
|
-
"""
|
|
1155
|
-
Update progress indicator during directory scanning.
|
|
1156
|
-
|
|
1157
|
-
Displays progress updates periodically for large directories (>1000 items).
|
|
1158
|
-
Updates are shown every 100 items to avoid excessive UI updates.
|
|
1159
|
-
|
|
1160
|
-
Args:
|
|
1161
|
-
progress_info: Dict containing:
|
|
1162
|
-
- count: Current number of items processed
|
|
1163
|
-
- text_widget: Text widget to display progress
|
|
1164
|
-
- tab_name: Name of the tab (for display)
|
|
1165
|
-
- last_update: Last count when progress was updated
|
|
1166
|
-
|
|
1167
|
-
Requirements: 8.3
|
|
1168
|
-
"""
|
|
1169
|
-
count = progress_info['count']
|
|
1170
|
-
last_update = progress_info['last_update']
|
|
1171
|
-
|
|
1172
|
-
# Only show progress for directories with >1000 items
|
|
1173
|
-
# Update every 100 items to avoid excessive UI updates
|
|
1174
|
-
if count > 1000 and (count - last_update) >= 100:
|
|
1175
|
-
text_widget = progress_info['text_widget']
|
|
1176
|
-
tab_name = progress_info['tab_name']
|
|
1177
|
-
|
|
1178
|
-
# Update the progress message
|
|
1179
|
-
text_widget.delete('1.0', tk.END)
|
|
1180
|
-
progress_msg = f"Scanning {tab_name} folder...\n"
|
|
1181
|
-
progress_msg += f"Items processed: {count:,}\n"
|
|
1182
|
-
progress_msg += "Please wait..."
|
|
1183
|
-
text_widget.insert('1.0', progress_msg)
|
|
1184
|
-
text_widget.update_idletasks()
|
|
1185
|
-
|
|
1186
|
-
# Update last_update counter
|
|
1187
|
-
progress_info['last_update'] = count
|
|
1188
|
-
|
|
1189
|
-
def _get_file_info(self, file_path):
|
|
1190
|
-
"""
|
|
1191
|
-
Get file metadata using os.stat().
|
|
1192
|
-
|
|
1193
|
-
Extracts file size in bytes, modification timestamp, and determines
|
|
1194
|
-
if the item is a file or folder. Handles permission errors and
|
|
1195
|
-
inaccessible files gracefully by logging errors and returning None.
|
|
1196
|
-
|
|
1197
|
-
Args:
|
|
1198
|
-
file_path: Path to the file or folder
|
|
1199
|
-
|
|
1200
|
-
Returns:
|
|
1201
|
-
FileInfo: Named tuple containing file metadata, or None if file is inaccessible
|
|
1202
|
-
- full_path: Complete path to file/folder
|
|
1203
|
-
- name: File/folder name
|
|
1204
|
-
- size: Size in bytes (0 for folders)
|
|
1205
|
-
- modified_time: Timestamp of last modification
|
|
1206
|
-
- is_folder: True if folder, False if file
|
|
1207
|
-
|
|
1208
|
-
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 8.2, 8.5
|
|
1209
|
-
"""
|
|
1210
|
-
try:
|
|
1211
|
-
# Normalize the file path to use consistent separators
|
|
1212
|
-
file_path = os.path.normpath(file_path)
|
|
1213
|
-
|
|
1214
|
-
# Get file statistics using os.stat()
|
|
1215
|
-
stat_info = os.stat(file_path)
|
|
1216
|
-
|
|
1217
|
-
# Extract file name from path
|
|
1218
|
-
file_name = os.path.basename(file_path)
|
|
1219
|
-
|
|
1220
|
-
# Determine if item is a file or folder
|
|
1221
|
-
is_folder = os.path.isdir(file_path)
|
|
1222
|
-
|
|
1223
|
-
# Extract file size in bytes (0 for folders)
|
|
1224
|
-
# For folders, we report 0 as the size (not the sum of contents)
|
|
1225
|
-
file_size = 0 if is_folder else stat_info.st_size
|
|
1226
|
-
|
|
1227
|
-
# Extract modification timestamp
|
|
1228
|
-
modified_time = stat_info.st_mtime
|
|
1229
|
-
|
|
1230
|
-
# Create and return FileInfo named tuple
|
|
1231
|
-
return FileInfo(
|
|
1232
|
-
full_path=file_path,
|
|
1233
|
-
name=file_name,
|
|
1234
|
-
size=file_size,
|
|
1235
|
-
modified_time=modified_time,
|
|
1236
|
-
is_folder=is_folder
|
|
1237
|
-
)
|
|
1238
|
-
|
|
1239
|
-
except PermissionError as e:
|
|
1240
|
-
# Handle permission errors - log and return None (Requirement 8.2, 7.5)
|
|
1241
|
-
error_msg = f"Permission denied accessing: {file_path} - {e}"
|
|
1242
|
-
self._log_error(error_msg)
|
|
1243
|
-
return None
|
|
1244
|
-
|
|
1245
|
-
except FileNotFoundError as e:
|
|
1246
|
-
# Handle files that were deleted during scan - log and return None (Requirement 7.5)
|
|
1247
|
-
error_msg = f"File not found (may have been deleted): {file_path} - {e}"
|
|
1248
|
-
self._log_error(error_msg)
|
|
1249
|
-
return None
|
|
1250
|
-
|
|
1251
|
-
except OSError as e:
|
|
1252
|
-
# Handle other OS errors (symbolic link issues, etc.) - log and return None (Requirement 8.5)
|
|
1253
|
-
error_msg = f"OS error accessing: {file_path} - {e}"
|
|
1254
|
-
self._log_error(error_msg)
|
|
1255
|
-
return None
|
|
1256
|
-
|
|
1257
|
-
except Exception as e:
|
|
1258
|
-
# Catch any other unexpected errors - log and return None (Requirement 8.5)
|
|
1259
|
-
error_msg = f"Unexpected error accessing: {file_path} - {e}"
|
|
1260
|
-
self._log_error(error_msg)
|
|
1261
|
-
return None
|
|
1262
|
-
|
|
1263
|
-
def _scan_directory(self, folder_path, current_depth=0, visited_paths=None, errors_list=None, progress_info=None):
|
|
1264
|
-
"""
|
|
1265
|
-
Recursively scan directory and collect file/folder information.
|
|
1266
|
-
|
|
1267
|
-
This method traverses the directory structure based on the configured recursion
|
|
1268
|
-
mode and depth settings. It handles symbolic links and circular references to
|
|
1269
|
-
prevent infinite loops, and applies the "Folders Only" filter during scanning.
|
|
1270
|
-
|
|
1271
|
-
Errors encountered during scanning are logged and added to the errors_list
|
|
1272
|
-
if provided, allowing processing to continue for remaining items.
|
|
1273
|
-
|
|
1274
|
-
Progress tracking is enabled when progress_info is provided, displaying
|
|
1275
|
-
periodic updates for large directories (>1000 items).
|
|
1276
|
-
|
|
1277
|
-
Args:
|
|
1278
|
-
folder_path: Path to the directory to scan
|
|
1279
|
-
current_depth: Current recursion depth (0 = root folder)
|
|
1280
|
-
visited_paths: Set of visited real paths to detect circular references
|
|
1281
|
-
errors_list: Optional list to collect error messages encountered during scan
|
|
1282
|
-
progress_info: Optional dict with progress tracking info (count, text_widget, tab_name, last_update)
|
|
1283
|
-
|
|
1284
|
-
Returns:
|
|
1285
|
-
List of FileInfo namedtuples containing file/folder information
|
|
1286
|
-
|
|
1287
|
-
Requirements: 4.4, 5.2, 5.3, 5.4, 5.5, 5.6, 7.5, 8.2, 8.3, 8.5
|
|
1288
|
-
"""
|
|
1289
|
-
# Normalize the folder path to use consistent separators
|
|
1290
|
-
folder_path = os.path.normpath(folder_path)
|
|
1291
|
-
|
|
1292
|
-
# Initialize visited paths set on first call to track circular references
|
|
1293
|
-
if visited_paths is None:
|
|
1294
|
-
visited_paths = set()
|
|
1295
|
-
|
|
1296
|
-
# Get the real path to handle symbolic links (Requirement 5.6)
|
|
1297
|
-
try:
|
|
1298
|
-
real_path = os.path.realpath(folder_path)
|
|
1299
|
-
except (OSError, ValueError) as e:
|
|
1300
|
-
error_msg = f"Could not resolve real path for {folder_path}: {e}"
|
|
1301
|
-
self._log_error(error_msg)
|
|
1302
|
-
if errors_list is not None:
|
|
1303
|
-
errors_list.append(error_msg)
|
|
1304
|
-
return []
|
|
1305
|
-
|
|
1306
|
-
# Check for circular reference - if we've already visited this path, skip it (Requirement 5.6)
|
|
1307
|
-
if real_path in visited_paths:
|
|
1308
|
-
warning_msg = f"Circular reference detected at {folder_path}, skipping"
|
|
1309
|
-
self._log_error(warning_msg)
|
|
1310
|
-
if errors_list is not None:
|
|
1311
|
-
errors_list.append(warning_msg)
|
|
1312
|
-
return []
|
|
1313
|
-
|
|
1314
|
-
# Add current path to visited set
|
|
1315
|
-
visited_paths.add(real_path)
|
|
1316
|
-
|
|
1317
|
-
# List to collect file information
|
|
1318
|
-
items = []
|
|
1319
|
-
|
|
1320
|
-
# Check if folder exists and is accessible
|
|
1321
|
-
if not os.path.exists(folder_path):
|
|
1322
|
-
error_msg = f"Folder does not exist: {folder_path}"
|
|
1323
|
-
self._log_error(error_msg)
|
|
1324
|
-
if errors_list is not None:
|
|
1325
|
-
errors_list.append(error_msg)
|
|
1326
|
-
return []
|
|
1327
|
-
|
|
1328
|
-
if not os.path.isdir(folder_path):
|
|
1329
|
-
error_msg = f"Path is not a directory: {folder_path}"
|
|
1330
|
-
self._log_error(error_msg)
|
|
1331
|
-
if errors_list is not None:
|
|
1332
|
-
errors_list.append(error_msg)
|
|
1333
|
-
return []
|
|
1334
|
-
|
|
1335
|
-
# Get recursion settings
|
|
1336
|
-
recursion_mode = self.recursion_mode.get()
|
|
1337
|
-
max_depth = self.recursion_depth.get() if recursion_mode == "limited" else -1
|
|
1338
|
-
folders_only = self.folders_only.get()
|
|
1339
|
-
|
|
1340
|
-
# Determine if we should recurse into subdirectories
|
|
1341
|
-
should_recurse = False
|
|
1342
|
-
if recursion_mode == "full":
|
|
1343
|
-
should_recurse = True
|
|
1344
|
-
elif recursion_mode == "limited" and current_depth < max_depth:
|
|
1345
|
-
should_recurse = True
|
|
1346
|
-
# recursion_mode == "none" means should_recurse stays False
|
|
1347
|
-
|
|
1348
|
-
try:
|
|
1349
|
-
# Get list of items in the directory
|
|
1350
|
-
dir_entries = os.listdir(folder_path)
|
|
1351
|
-
except PermissionError as e:
|
|
1352
|
-
# Handle permission errors - log and continue (Requirement 8.2)
|
|
1353
|
-
error_msg = f"Permission denied accessing {folder_path}: {e}"
|
|
1354
|
-
self._log_error(error_msg)
|
|
1355
|
-
if errors_list is not None:
|
|
1356
|
-
errors_list.append(error_msg)
|
|
1357
|
-
return []
|
|
1358
|
-
except OSError as e:
|
|
1359
|
-
# Handle OS errors - log and continue (Requirement 8.5)
|
|
1360
|
-
error_msg = f"Error reading directory {folder_path}: {e}"
|
|
1361
|
-
self._log_error(error_msg)
|
|
1362
|
-
if errors_list is not None:
|
|
1363
|
-
errors_list.append(error_msg)
|
|
1364
|
-
return []
|
|
1365
|
-
|
|
1366
|
-
# Process each item in the directory
|
|
1367
|
-
for entry_name in dir_entries:
|
|
1368
|
-
# Construct full path and normalize separators
|
|
1369
|
-
entry_path = os.path.normpath(os.path.join(folder_path, entry_name))
|
|
1370
|
-
|
|
1371
|
-
try:
|
|
1372
|
-
# Get file information using os.stat()
|
|
1373
|
-
# Use lstat() to not follow symbolic links initially
|
|
1374
|
-
stat_info = os.lstat(entry_path)
|
|
1375
|
-
|
|
1376
|
-
# Check if this is a symbolic link
|
|
1377
|
-
is_symlink = os.path.islink(entry_path)
|
|
1378
|
-
|
|
1379
|
-
# Determine if this is a directory
|
|
1380
|
-
# For symlinks, check what they point to
|
|
1381
|
-
if is_symlink:
|
|
1382
|
-
try:
|
|
1383
|
-
# Follow the symlink to see if it points to a directory
|
|
1384
|
-
is_dir = os.path.isdir(entry_path)
|
|
1385
|
-
except (OSError, ValueError) as e:
|
|
1386
|
-
# Broken symlink or permission error
|
|
1387
|
-
error_msg = f"Error following symlink {entry_path}: {e}"
|
|
1388
|
-
self._log_error(error_msg)
|
|
1389
|
-
if errors_list is not None:
|
|
1390
|
-
errors_list.append(error_msg)
|
|
1391
|
-
is_dir = False
|
|
1392
|
-
else:
|
|
1393
|
-
is_dir = os.path.isdir(entry_path)
|
|
1394
|
-
|
|
1395
|
-
# Apply "Folders Only" filter
|
|
1396
|
-
if folders_only and not is_dir:
|
|
1397
|
-
continue # Skip files when folders_only is enabled
|
|
1398
|
-
|
|
1399
|
-
# Get file size (0 for directories)
|
|
1400
|
-
size = 0 if is_dir else stat_info.st_size
|
|
1401
|
-
|
|
1402
|
-
# Get modification time
|
|
1403
|
-
modified_time = stat_info.st_mtime
|
|
1404
|
-
|
|
1405
|
-
# Create FileInfo tuple
|
|
1406
|
-
file_info = FileInfo(
|
|
1407
|
-
full_path=entry_path,
|
|
1408
|
-
name=entry_name,
|
|
1409
|
-
size=size,
|
|
1410
|
-
modified_time=modified_time,
|
|
1411
|
-
is_folder=is_dir
|
|
1412
|
-
)
|
|
1413
|
-
|
|
1414
|
-
# Add to items list
|
|
1415
|
-
items.append(file_info)
|
|
1416
|
-
|
|
1417
|
-
# Update progress tracking if enabled (Requirement 8.3)
|
|
1418
|
-
if progress_info is not None:
|
|
1419
|
-
progress_info['count'] += 1
|
|
1420
|
-
self._update_progress(progress_info)
|
|
1421
|
-
|
|
1422
|
-
# Recursively scan subdirectories if appropriate
|
|
1423
|
-
if is_dir and should_recurse:
|
|
1424
|
-
# Recursively scan the subdirectory
|
|
1425
|
-
sub_items = self._scan_directory(
|
|
1426
|
-
entry_path,
|
|
1427
|
-
current_depth=current_depth + 1,
|
|
1428
|
-
visited_paths=visited_paths,
|
|
1429
|
-
errors_list=errors_list,
|
|
1430
|
-
progress_info=progress_info
|
|
1431
|
-
)
|
|
1432
|
-
items.extend(sub_items)
|
|
1433
|
-
|
|
1434
|
-
except PermissionError as e:
|
|
1435
|
-
# Handle permission errors - log and continue (Requirement 8.2, 7.5)
|
|
1436
|
-
error_msg = f"Permission denied accessing {entry_path}: {e}"
|
|
1437
|
-
self._log_error(error_msg)
|
|
1438
|
-
if errors_list is not None:
|
|
1439
|
-
errors_list.append(error_msg)
|
|
1440
|
-
continue
|
|
1441
|
-
except FileNotFoundError as e:
|
|
1442
|
-
# Handle files deleted during scan - log and continue (Requirement 7.5)
|
|
1443
|
-
error_msg = f"File not found (may have been deleted): {entry_path}: {e}"
|
|
1444
|
-
self._log_error(error_msg)
|
|
1445
|
-
if errors_list is not None:
|
|
1446
|
-
errors_list.append(error_msg)
|
|
1447
|
-
continue
|
|
1448
|
-
except OSError as e:
|
|
1449
|
-
# Handle other OS errors - log and continue (Requirement 8.5)
|
|
1450
|
-
error_msg = f"Error accessing {entry_path}: {e}"
|
|
1451
|
-
self._log_error(error_msg)
|
|
1452
|
-
if errors_list is not None:
|
|
1453
|
-
errors_list.append(error_msg)
|
|
1454
|
-
continue
|
|
1455
|
-
except Exception as e:
|
|
1456
|
-
# Catch unexpected errors - log and continue (Requirement 8.5)
|
|
1457
|
-
error_msg = f"Unexpected error accessing {entry_path}: {e}"
|
|
1458
|
-
self._log_error(error_msg)
|
|
1459
|
-
if errors_list is not None:
|
|
1460
|
-
errors_list.append(error_msg)
|
|
1461
|
-
continue
|
|
1462
|
-
|
|
1463
|
-
return items
|
|
1
|
+
"""
|
|
2
|
+
Folder File Reporter Tool
|
|
3
|
+
|
|
4
|
+
Generates customizable reports of directory contents with flexible configuration
|
|
5
|
+
options for output formatting, selective information display, and recursive
|
|
6
|
+
directory traversal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import tkinter as tk
|
|
10
|
+
from tkinter import ttk, filedialog, messagebox
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections import namedtuple
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# File information data structure
|
|
18
|
+
FileInfo = namedtuple('FileInfo', [
|
|
19
|
+
'full_path', # str: Complete path to file/folder
|
|
20
|
+
'name', # str: File/folder name
|
|
21
|
+
'size', # int: Size in bytes (0 for folders)
|
|
22
|
+
'modified_time', # float: Timestamp of last modification
|
|
23
|
+
'is_folder' # bool: True if folder, False if file
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FolderFileReporter:
|
|
28
|
+
"""
|
|
29
|
+
A tool for generating detailed reports of folder contents with customizable
|
|
30
|
+
output formatting and filtering options.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, parent, dialog_manager=None, input_text_widget=None, output_text_widget=None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the Folder File Reporter tool.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
parent: Parent tkinter widget (tool window)
|
|
39
|
+
dialog_manager: Optional DialogManager instance for consistent dialogs
|
|
40
|
+
input_text_widget: Reference to main application's Input tab text widget
|
|
41
|
+
output_text_widget: Reference to main application's Output tab text widget
|
|
42
|
+
"""
|
|
43
|
+
self.parent = parent
|
|
44
|
+
self.dialog_manager = dialog_manager
|
|
45
|
+
self.input_text_widget = input_text_widget
|
|
46
|
+
self.output_text_widget = output_text_widget
|
|
47
|
+
|
|
48
|
+
# Settings file path
|
|
49
|
+
self.settings_file = "settings.json"
|
|
50
|
+
self.tool_key = "Folder File Reporter" # Key in tool_settings section
|
|
51
|
+
|
|
52
|
+
# Initialize UI variables
|
|
53
|
+
self.input_folder_path = tk.StringVar()
|
|
54
|
+
self.output_folder_path = tk.StringVar()
|
|
55
|
+
|
|
56
|
+
# Field selection variables
|
|
57
|
+
self.field_selections = {
|
|
58
|
+
'path': tk.BooleanVar(value=True),
|
|
59
|
+
'name': tk.BooleanVar(value=True),
|
|
60
|
+
'size': tk.BooleanVar(value=True),
|
|
61
|
+
'date_modified': tk.BooleanVar(value=True)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Configuration variables
|
|
65
|
+
self.separator = tk.StringVar(value=" | ")
|
|
66
|
+
self.folders_only = tk.BooleanVar(value=False)
|
|
67
|
+
self.recursion_mode = tk.StringVar(value="full")
|
|
68
|
+
self.recursion_depth = tk.IntVar(value=2)
|
|
69
|
+
self.size_format = tk.StringVar(value="human")
|
|
70
|
+
self.date_format = tk.StringVar(value="%Y-%m-%d %H:%M:%S")
|
|
71
|
+
|
|
72
|
+
# Load saved settings
|
|
73
|
+
self.load_settings()
|
|
74
|
+
|
|
75
|
+
# Create UI
|
|
76
|
+
self._create_ui()
|
|
77
|
+
|
|
78
|
+
def load_settings(self):
|
|
79
|
+
"""
|
|
80
|
+
Load settings from centralized settings.json file.
|
|
81
|
+
|
|
82
|
+
Loads user preferences including field selections, separator, recursion mode,
|
|
83
|
+
and last used folder paths. If the settings file doesn't exist or is invalid,
|
|
84
|
+
default values are used.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
if os.path.exists(self.settings_file):
|
|
88
|
+
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
|
89
|
+
all_settings = json.load(f)
|
|
90
|
+
|
|
91
|
+
# Get tool settings from tool_settings section
|
|
92
|
+
tool_settings = all_settings.get("tool_settings", {})
|
|
93
|
+
settings = tool_settings.get(self.tool_key, {})
|
|
94
|
+
|
|
95
|
+
# Load field selections
|
|
96
|
+
if 'field_selections' in settings:
|
|
97
|
+
for field, value in settings['field_selections'].items():
|
|
98
|
+
if field in self.field_selections:
|
|
99
|
+
self.field_selections[field].set(value)
|
|
100
|
+
|
|
101
|
+
# Load separator
|
|
102
|
+
if 'separator' in settings:
|
|
103
|
+
self.separator.set(settings['separator'])
|
|
104
|
+
|
|
105
|
+
# Load folders only setting
|
|
106
|
+
if 'folders_only' in settings:
|
|
107
|
+
self.folders_only.set(settings['folders_only'])
|
|
108
|
+
|
|
109
|
+
# Load recursion settings
|
|
110
|
+
if 'recursion_mode' in settings:
|
|
111
|
+
self.recursion_mode.set(settings['recursion_mode'])
|
|
112
|
+
if 'recursion_depth' in settings:
|
|
113
|
+
self.recursion_depth.set(settings['recursion_depth'])
|
|
114
|
+
|
|
115
|
+
# Load size format
|
|
116
|
+
if 'size_format' in settings:
|
|
117
|
+
self.size_format.set(settings['size_format'])
|
|
118
|
+
|
|
119
|
+
# Load date format
|
|
120
|
+
if 'date_format' in settings:
|
|
121
|
+
self.date_format.set(settings['date_format'])
|
|
122
|
+
|
|
123
|
+
# Load last used folders
|
|
124
|
+
if 'last_input_folder' in settings:
|
|
125
|
+
self.input_folder_path.set(settings['last_input_folder'])
|
|
126
|
+
if 'last_output_folder' in settings:
|
|
127
|
+
self.output_folder_path.set(settings['last_output_folder'])
|
|
128
|
+
|
|
129
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
130
|
+
# If settings file is corrupted or can't be read, use defaults
|
|
131
|
+
print(f"Warning: Could not load settings from {self.settings_file}: {e}")
|
|
132
|
+
print("Using default settings")
|
|
133
|
+
|
|
134
|
+
def save_settings(self):
|
|
135
|
+
"""
|
|
136
|
+
Save current settings to centralized settings.json file.
|
|
137
|
+
|
|
138
|
+
Persists user preferences including field selections, separator, recursion mode,
|
|
139
|
+
and last used folder paths for future sessions.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
# Load existing settings file
|
|
143
|
+
all_settings = {}
|
|
144
|
+
if os.path.exists(self.settings_file):
|
|
145
|
+
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
|
146
|
+
all_settings = json.load(f)
|
|
147
|
+
|
|
148
|
+
# Ensure tool_settings section exists
|
|
149
|
+
if "tool_settings" not in all_settings:
|
|
150
|
+
all_settings["tool_settings"] = {}
|
|
151
|
+
|
|
152
|
+
# Update Folder File Reporter settings
|
|
153
|
+
all_settings["tool_settings"][self.tool_key] = {
|
|
154
|
+
'field_selections': {
|
|
155
|
+
field: var.get() for field, var in self.field_selections.items()
|
|
156
|
+
},
|
|
157
|
+
'separator': self.separator.get(),
|
|
158
|
+
'folders_only': self.folders_only.get(),
|
|
159
|
+
'recursion_mode': self.recursion_mode.get(),
|
|
160
|
+
'recursion_depth': self.recursion_depth.get(),
|
|
161
|
+
'size_format': self.size_format.get(),
|
|
162
|
+
'date_format': self.date_format.get(),
|
|
163
|
+
'last_input_folder': self.input_folder_path.get(),
|
|
164
|
+
'last_output_folder': self.output_folder_path.get()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Write settings to file
|
|
168
|
+
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
|
169
|
+
json.dump(all_settings, f, indent=4, ensure_ascii=False)
|
|
170
|
+
|
|
171
|
+
except IOError as e:
|
|
172
|
+
print(f"Warning: Could not save settings to {self.settings_file}: {e}")
|
|
173
|
+
|
|
174
|
+
def _create_ui(self):
|
|
175
|
+
"""
|
|
176
|
+
Create the main user interface.
|
|
177
|
+
|
|
178
|
+
Sets up the main frame structure with tabs at the top and options panel below.
|
|
179
|
+
"""
|
|
180
|
+
# Main container frame
|
|
181
|
+
main_frame = ttk.Frame(self.parent)
|
|
182
|
+
main_frame.pack(expand=True, fill='both', padx=5, pady=5)
|
|
183
|
+
|
|
184
|
+
# Create tab notebook at the top
|
|
185
|
+
self._create_tab_notebook(main_frame)
|
|
186
|
+
|
|
187
|
+
# Create options panel below tabs
|
|
188
|
+
self._create_options_panel_two_column(main_frame)
|
|
189
|
+
|
|
190
|
+
# Create Process button below options panel
|
|
191
|
+
self._create_process_button(main_frame)
|
|
192
|
+
|
|
193
|
+
def _create_tab_notebook(self, parent):
|
|
194
|
+
"""
|
|
195
|
+
Create Input and Output tabs with scrolled text widgets.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
parent: Parent frame to contain the notebook
|
|
199
|
+
"""
|
|
200
|
+
# Create notebook widget
|
|
201
|
+
self.notebook = ttk.Notebook(parent)
|
|
202
|
+
self.notebook.pack(expand=True, fill='both', pady=(0, 10))
|
|
203
|
+
|
|
204
|
+
# Create Input tab
|
|
205
|
+
input_frame = ttk.Frame(self.notebook)
|
|
206
|
+
self.notebook.add(input_frame, text="Input")
|
|
207
|
+
|
|
208
|
+
# Create scrolled text widget for Input tab
|
|
209
|
+
self.input_text = tk.Text(
|
|
210
|
+
input_frame,
|
|
211
|
+
wrap='none',
|
|
212
|
+
width=80,
|
|
213
|
+
height=15,
|
|
214
|
+
font=('Courier', 9)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Add scrollbars for Input text widget
|
|
218
|
+
input_v_scrollbar = ttk.Scrollbar(input_frame, orient='vertical', command=self.input_text.yview)
|
|
219
|
+
input_h_scrollbar = ttk.Scrollbar(input_frame, orient='horizontal', command=self.input_text.xview)
|
|
220
|
+
self.input_text.configure(yscrollcommand=input_v_scrollbar.set, xscrollcommand=input_h_scrollbar.set)
|
|
221
|
+
|
|
222
|
+
# Grid layout for Input tab
|
|
223
|
+
self.input_text.grid(row=0, column=0, sticky='nsew')
|
|
224
|
+
input_v_scrollbar.grid(row=0, column=1, sticky='ns')
|
|
225
|
+
input_h_scrollbar.grid(row=1, column=0, sticky='ew')
|
|
226
|
+
|
|
227
|
+
input_frame.grid_rowconfigure(0, weight=1)
|
|
228
|
+
input_frame.grid_columnconfigure(0, weight=1)
|
|
229
|
+
|
|
230
|
+
# Create Output tab
|
|
231
|
+
output_frame = ttk.Frame(self.notebook)
|
|
232
|
+
self.notebook.add(output_frame, text="Output")
|
|
233
|
+
|
|
234
|
+
# Create scrolled text widget for Output tab
|
|
235
|
+
self.output_text = tk.Text(
|
|
236
|
+
output_frame,
|
|
237
|
+
wrap='none',
|
|
238
|
+
width=80,
|
|
239
|
+
height=15,
|
|
240
|
+
font=('Courier', 9)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Add scrollbars for Output text widget
|
|
244
|
+
output_v_scrollbar = ttk.Scrollbar(output_frame, orient='vertical', command=self.output_text.yview)
|
|
245
|
+
output_h_scrollbar = ttk.Scrollbar(output_frame, orient='horizontal', command=self.output_text.xview)
|
|
246
|
+
self.output_text.configure(yscrollcommand=output_v_scrollbar.set, xscrollcommand=output_h_scrollbar.set)
|
|
247
|
+
|
|
248
|
+
# Grid layout for Output tab
|
|
249
|
+
self.output_text.grid(row=0, column=0, sticky='nsew')
|
|
250
|
+
output_v_scrollbar.grid(row=0, column=1, sticky='ns')
|
|
251
|
+
output_h_scrollbar.grid(row=1, column=0, sticky='ew')
|
|
252
|
+
|
|
253
|
+
output_frame.grid_rowconfigure(0, weight=1)
|
|
254
|
+
output_frame.grid_columnconfigure(0, weight=1)
|
|
255
|
+
|
|
256
|
+
# Add context menu support if available
|
|
257
|
+
try:
|
|
258
|
+
from core.context_menu import add_context_menu
|
|
259
|
+
self.input_text._context_menu = add_context_menu(self.input_text)
|
|
260
|
+
self.output_text._context_menu = add_context_menu(self.output_text)
|
|
261
|
+
except ImportError:
|
|
262
|
+
# Context menu module not available, skip
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
def _create_options_panel_two_column(self, parent):
|
|
266
|
+
"""
|
|
267
|
+
Create options panel with two-column layout.
|
|
268
|
+
|
|
269
|
+
Left column: Input folder, field selections, separator, folders only
|
|
270
|
+
Right column: Output folder, recursion mode, size format
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
parent: Parent frame to contain the options panel
|
|
274
|
+
"""
|
|
275
|
+
# Options panel container
|
|
276
|
+
options_frame = ttk.LabelFrame(parent, text="Options", padding=10)
|
|
277
|
+
options_frame.pack(fill='x', pady=(0, 10))
|
|
278
|
+
|
|
279
|
+
# Create two-column layout
|
|
280
|
+
left_column = ttk.Frame(options_frame)
|
|
281
|
+
left_column.grid(row=0, column=0, sticky='nsew', padx=(0, 10))
|
|
282
|
+
|
|
283
|
+
right_column = ttk.Frame(options_frame)
|
|
284
|
+
right_column.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
|
|
285
|
+
|
|
286
|
+
# Configure grid weights for equal column widths
|
|
287
|
+
options_frame.grid_columnconfigure(0, weight=1)
|
|
288
|
+
options_frame.grid_columnconfigure(1, weight=1)
|
|
289
|
+
|
|
290
|
+
# Store references for later use in other tasks
|
|
291
|
+
self.left_column = left_column
|
|
292
|
+
self.right_column = right_column
|
|
293
|
+
|
|
294
|
+
# Populate columns with controls
|
|
295
|
+
self._create_folder_selection_controls()
|
|
296
|
+
self._create_field_selection_checkboxes()
|
|
297
|
+
self._create_separator_and_filter_controls()
|
|
298
|
+
self._create_recursion_mode_controls()
|
|
299
|
+
self._create_size_format_controls()
|
|
300
|
+
|
|
301
|
+
def _create_folder_selection_controls(self):
|
|
302
|
+
"""
|
|
303
|
+
Create folder picker UI elements in left and right columns.
|
|
304
|
+
|
|
305
|
+
Left column: Input folder selection
|
|
306
|
+
Right column: Output folder selection
|
|
307
|
+
Each includes a label, entry widget displaying the path, and Browse button.
|
|
308
|
+
"""
|
|
309
|
+
# LEFT COLUMN - Input Folder Selection
|
|
310
|
+
input_folder_label = ttk.Label(self.left_column, text="Input Folder:", font=('TkDefaultFont', 9, 'bold'))
|
|
311
|
+
input_folder_label.grid(row=0, column=0, sticky='w', pady=(0, 5))
|
|
312
|
+
|
|
313
|
+
# Entry widget to display selected input folder path
|
|
314
|
+
self.input_folder_entry = ttk.Entry(
|
|
315
|
+
self.left_column,
|
|
316
|
+
textvariable=self.input_folder_path,
|
|
317
|
+
width=40,
|
|
318
|
+
state='readonly'
|
|
319
|
+
)
|
|
320
|
+
self.input_folder_entry.grid(row=1, column=0, sticky='ew', pady=(0, 5))
|
|
321
|
+
|
|
322
|
+
# Browse button for input folder
|
|
323
|
+
input_browse_btn = ttk.Button(
|
|
324
|
+
self.left_column,
|
|
325
|
+
text="Browse...",
|
|
326
|
+
command=self._browse_input_folder
|
|
327
|
+
)
|
|
328
|
+
input_browse_btn.grid(row=2, column=0, sticky='w', pady=(0, 15))
|
|
329
|
+
|
|
330
|
+
# Configure column weight for entry widget expansion
|
|
331
|
+
self.left_column.grid_columnconfigure(0, weight=1)
|
|
332
|
+
|
|
333
|
+
# RIGHT COLUMN - Output Folder Selection
|
|
334
|
+
output_folder_label = ttk.Label(self.right_column, text="Output Folder:", font=('TkDefaultFont', 9, 'bold'))
|
|
335
|
+
output_folder_label.grid(row=0, column=0, sticky='w', pady=(0, 5))
|
|
336
|
+
|
|
337
|
+
# Entry widget to display selected output folder path
|
|
338
|
+
self.output_folder_entry = ttk.Entry(
|
|
339
|
+
self.right_column,
|
|
340
|
+
textvariable=self.output_folder_path,
|
|
341
|
+
width=40,
|
|
342
|
+
state='readonly'
|
|
343
|
+
)
|
|
344
|
+
self.output_folder_entry.grid(row=1, column=0, sticky='ew', pady=(0, 5))
|
|
345
|
+
|
|
346
|
+
# Browse button for output folder
|
|
347
|
+
output_browse_btn = ttk.Button(
|
|
348
|
+
self.right_column,
|
|
349
|
+
text="Browse...",
|
|
350
|
+
command=self._browse_output_folder
|
|
351
|
+
)
|
|
352
|
+
output_browse_btn.grid(row=2, column=0, sticky='w', pady=(0, 15))
|
|
353
|
+
|
|
354
|
+
# Configure column weight for entry widget expansion
|
|
355
|
+
self.right_column.grid_columnconfigure(0, weight=1)
|
|
356
|
+
|
|
357
|
+
def _browse_input_folder(self):
|
|
358
|
+
"""
|
|
359
|
+
Open native folder selection dialog for Input folder.
|
|
360
|
+
|
|
361
|
+
Uses filedialog.askdirectory() to allow user to select a folder.
|
|
362
|
+
Updates the input_folder_path StringVar with the selected path.
|
|
363
|
+
Saves settings after selection.
|
|
364
|
+
"""
|
|
365
|
+
# Get initial directory from current selection or user's home directory
|
|
366
|
+
initial_dir = self.input_folder_path.get() or os.path.expanduser("~")
|
|
367
|
+
|
|
368
|
+
# Open folder selection dialog
|
|
369
|
+
selected_folder = filedialog.askdirectory(
|
|
370
|
+
title="Select Input Folder",
|
|
371
|
+
initialdir=initial_dir if os.path.exists(initial_dir) else os.path.expanduser("~"),
|
|
372
|
+
parent=self.parent
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Update path if user selected a folder (not cancelled)
|
|
376
|
+
if selected_folder:
|
|
377
|
+
self.input_folder_path.set(selected_folder)
|
|
378
|
+
self.save_settings()
|
|
379
|
+
|
|
380
|
+
def _browse_output_folder(self):
|
|
381
|
+
"""
|
|
382
|
+
Open native folder selection dialog for Output folder.
|
|
383
|
+
|
|
384
|
+
Uses filedialog.askdirectory() to allow user to select a folder.
|
|
385
|
+
Updates the output_folder_path StringVar with the selected path.
|
|
386
|
+
Saves settings after selection.
|
|
387
|
+
"""
|
|
388
|
+
# Get initial directory from current selection or user's home directory
|
|
389
|
+
initial_dir = self.output_folder_path.get() or os.path.expanduser("~")
|
|
390
|
+
|
|
391
|
+
# Open folder selection dialog
|
|
392
|
+
selected_folder = filedialog.askdirectory(
|
|
393
|
+
title="Select Output Folder",
|
|
394
|
+
initialdir=initial_dir if os.path.exists(initial_dir) else os.path.expanduser("~"),
|
|
395
|
+
parent=self.parent
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Update path if user selected a folder (not cancelled)
|
|
399
|
+
if selected_folder:
|
|
400
|
+
self.output_folder_path.set(selected_folder)
|
|
401
|
+
self.save_settings()
|
|
402
|
+
|
|
403
|
+
def _create_field_selection_checkboxes(self):
|
|
404
|
+
"""
|
|
405
|
+
Create information field selection checkboxes in the left column.
|
|
406
|
+
|
|
407
|
+
Creates checkboxes for Path, File Name, Size, and Date Modified.
|
|
408
|
+
Each checkbox is bound to a BooleanVar and saves settings when changed.
|
|
409
|
+
Checkbox states are loaded from settings on initialization.
|
|
410
|
+
"""
|
|
411
|
+
# Information Fields section label
|
|
412
|
+
fields_label = ttk.Label(
|
|
413
|
+
self.left_column,
|
|
414
|
+
text="Information Fields:",
|
|
415
|
+
font=('TkDefaultFont', 9, 'bold')
|
|
416
|
+
)
|
|
417
|
+
fields_label.grid(row=3, column=0, sticky='w', pady=(0, 5))
|
|
418
|
+
|
|
419
|
+
# Create frame to contain checkboxes
|
|
420
|
+
fields_frame = ttk.Frame(self.left_column)
|
|
421
|
+
fields_frame.grid(row=4, column=0, sticky='w', pady=(0, 15))
|
|
422
|
+
|
|
423
|
+
# Create checkboxes for each field
|
|
424
|
+
# Path checkbox
|
|
425
|
+
path_checkbox = ttk.Checkbutton(
|
|
426
|
+
fields_frame,
|
|
427
|
+
text="Path",
|
|
428
|
+
variable=self.field_selections['path'],
|
|
429
|
+
command=self._on_field_selection_changed
|
|
430
|
+
)
|
|
431
|
+
path_checkbox.grid(row=0, column=0, sticky='w', pady=2)
|
|
432
|
+
|
|
433
|
+
# File Name checkbox
|
|
434
|
+
name_checkbox = ttk.Checkbutton(
|
|
435
|
+
fields_frame,
|
|
436
|
+
text="File Name",
|
|
437
|
+
variable=self.field_selections['name'],
|
|
438
|
+
command=self._on_field_selection_changed
|
|
439
|
+
)
|
|
440
|
+
name_checkbox.grid(row=1, column=0, sticky='w', pady=2)
|
|
441
|
+
|
|
442
|
+
# Size checkbox
|
|
443
|
+
size_checkbox = ttk.Checkbutton(
|
|
444
|
+
fields_frame,
|
|
445
|
+
text="Size",
|
|
446
|
+
variable=self.field_selections['size'],
|
|
447
|
+
command=self._on_field_selection_changed
|
|
448
|
+
)
|
|
449
|
+
size_checkbox.grid(row=2, column=0, sticky='w', pady=2)
|
|
450
|
+
|
|
451
|
+
# Date Modified checkbox
|
|
452
|
+
date_checkbox = ttk.Checkbutton(
|
|
453
|
+
fields_frame,
|
|
454
|
+
text="Date Modified",
|
|
455
|
+
variable=self.field_selections['date_modified'],
|
|
456
|
+
command=self._on_field_selection_changed
|
|
457
|
+
)
|
|
458
|
+
date_checkbox.grid(row=3, column=0, sticky='w', pady=2)
|
|
459
|
+
|
|
460
|
+
def _on_field_selection_changed(self):
|
|
461
|
+
"""
|
|
462
|
+
Callback when any field selection checkbox is changed.
|
|
463
|
+
|
|
464
|
+
Saves the current settings to persist the user's field selections.
|
|
465
|
+
"""
|
|
466
|
+
self.save_settings()
|
|
467
|
+
|
|
468
|
+
def _create_separator_and_filter_controls(self):
|
|
469
|
+
"""
|
|
470
|
+
Create separator configuration and file type filtering controls in the left column.
|
|
471
|
+
|
|
472
|
+
Creates:
|
|
473
|
+
- Separator text entry field with default value " | "
|
|
474
|
+
- "Folders Only" checkbox for file type filtering
|
|
475
|
+
|
|
476
|
+
The separator field supports escape sequences (\t, \n, \\) which are processed
|
|
477
|
+
when generating reports.
|
|
478
|
+
"""
|
|
479
|
+
# Separator configuration section
|
|
480
|
+
separator_label = ttk.Label(
|
|
481
|
+
self.left_column,
|
|
482
|
+
text="Separator:",
|
|
483
|
+
font=('TkDefaultFont', 9, 'bold')
|
|
484
|
+
)
|
|
485
|
+
separator_label.grid(row=5, column=0, sticky='w', pady=(0, 5))
|
|
486
|
+
|
|
487
|
+
# Create frame for separator entry and hint
|
|
488
|
+
separator_frame = ttk.Frame(self.left_column)
|
|
489
|
+
separator_frame.grid(row=6, column=0, sticky='ew', pady=(0, 5))
|
|
490
|
+
|
|
491
|
+
# Separator entry field
|
|
492
|
+
self.separator_entry = ttk.Entry(
|
|
493
|
+
separator_frame,
|
|
494
|
+
textvariable=self.separator,
|
|
495
|
+
width=20
|
|
496
|
+
)
|
|
497
|
+
self.separator_entry.pack(side='left', padx=(0, 5))
|
|
498
|
+
|
|
499
|
+
# Hint label for escape sequences
|
|
500
|
+
separator_hint = ttk.Label(
|
|
501
|
+
separator_frame,
|
|
502
|
+
text="(Use \\t for tab, \\n for newline)",
|
|
503
|
+
font=('TkDefaultFont', 8),
|
|
504
|
+
foreground='gray'
|
|
505
|
+
)
|
|
506
|
+
separator_hint.pack(side='left')
|
|
507
|
+
|
|
508
|
+
# Bind change event to save settings
|
|
509
|
+
self.separator.trace_add('write', lambda *args: self.save_settings())
|
|
510
|
+
|
|
511
|
+
# Folders Only checkbox
|
|
512
|
+
folders_only_checkbox = ttk.Checkbutton(
|
|
513
|
+
self.left_column,
|
|
514
|
+
text="Folders Only",
|
|
515
|
+
variable=self.folders_only,
|
|
516
|
+
command=self._on_folders_only_changed
|
|
517
|
+
)
|
|
518
|
+
folders_only_checkbox.grid(row=7, column=0, sticky='w', pady=(10, 0))
|
|
519
|
+
|
|
520
|
+
def _on_folders_only_changed(self):
|
|
521
|
+
"""
|
|
522
|
+
Callback when the Folders Only checkbox is changed.
|
|
523
|
+
|
|
524
|
+
Saves the current settings to persist the user's file type filter preference.
|
|
525
|
+
"""
|
|
526
|
+
self.save_settings()
|
|
527
|
+
|
|
528
|
+
def _create_recursion_mode_controls(self):
|
|
529
|
+
"""
|
|
530
|
+
Create recursion mode controls in the right column.
|
|
531
|
+
|
|
532
|
+
Creates:
|
|
533
|
+
- Radio buttons for "None", "Limited", and "Full" recursion modes
|
|
534
|
+
- Spinbox for depth value (visible only when "Limited" is selected)
|
|
535
|
+
|
|
536
|
+
The controls are bound to recursion_mode (StringVar) and recursion_depth (IntVar).
|
|
537
|
+
Settings are saved when values change.
|
|
538
|
+
"""
|
|
539
|
+
# Recursion section label
|
|
540
|
+
recursion_label = ttk.Label(
|
|
541
|
+
self.right_column,
|
|
542
|
+
text="Recursion:",
|
|
543
|
+
font=('TkDefaultFont', 9, 'bold')
|
|
544
|
+
)
|
|
545
|
+
recursion_label.grid(row=3, column=0, sticky='w', pady=(0, 5))
|
|
546
|
+
|
|
547
|
+
# Create frame to contain radio buttons and depth control
|
|
548
|
+
recursion_frame = ttk.Frame(self.right_column)
|
|
549
|
+
recursion_frame.grid(row=4, column=0, sticky='w', pady=(0, 15))
|
|
550
|
+
|
|
551
|
+
# Radio button for "None" mode
|
|
552
|
+
none_radio = ttk.Radiobutton(
|
|
553
|
+
recursion_frame,
|
|
554
|
+
text="None",
|
|
555
|
+
variable=self.recursion_mode,
|
|
556
|
+
value="none",
|
|
557
|
+
command=self._on_recursion_mode_changed
|
|
558
|
+
)
|
|
559
|
+
none_radio.grid(row=0, column=0, sticky='w', pady=2)
|
|
560
|
+
|
|
561
|
+
# Radio button for "Limited" mode
|
|
562
|
+
limited_radio = ttk.Radiobutton(
|
|
563
|
+
recursion_frame,
|
|
564
|
+
text="Limited",
|
|
565
|
+
variable=self.recursion_mode,
|
|
566
|
+
value="limited",
|
|
567
|
+
command=self._on_recursion_mode_changed
|
|
568
|
+
)
|
|
569
|
+
limited_radio.grid(row=1, column=0, sticky='w', pady=2)
|
|
570
|
+
|
|
571
|
+
# Depth spinbox (shown only when Limited is selected)
|
|
572
|
+
depth_frame = ttk.Frame(recursion_frame)
|
|
573
|
+
depth_frame.grid(row=1, column=1, sticky='w', padx=(5, 0))
|
|
574
|
+
|
|
575
|
+
ttk.Label(depth_frame, text="Depth:").pack(side='left', padx=(0, 5))
|
|
576
|
+
|
|
577
|
+
self.depth_spinbox = ttk.Spinbox(
|
|
578
|
+
depth_frame,
|
|
579
|
+
from_=1,
|
|
580
|
+
to=20,
|
|
581
|
+
width=5,
|
|
582
|
+
textvariable=self.recursion_depth,
|
|
583
|
+
command=self._on_recursion_depth_changed
|
|
584
|
+
)
|
|
585
|
+
self.depth_spinbox.pack(side='left')
|
|
586
|
+
|
|
587
|
+
# Bind spinbox entry changes as well
|
|
588
|
+
self.recursion_depth.trace_add('write', lambda *args: self._on_recursion_depth_changed())
|
|
589
|
+
|
|
590
|
+
# Radio button for "Full" mode
|
|
591
|
+
full_radio = ttk.Radiobutton(
|
|
592
|
+
recursion_frame,
|
|
593
|
+
text="Full",
|
|
594
|
+
variable=self.recursion_mode,
|
|
595
|
+
value="full",
|
|
596
|
+
command=self._on_recursion_mode_changed
|
|
597
|
+
)
|
|
598
|
+
full_radio.grid(row=2, column=0, sticky='w', pady=2)
|
|
599
|
+
|
|
600
|
+
# Update depth spinbox visibility based on initial mode
|
|
601
|
+
self._update_depth_spinbox_visibility()
|
|
602
|
+
|
|
603
|
+
def _on_recursion_mode_changed(self):
|
|
604
|
+
"""
|
|
605
|
+
Callback when recursion mode radio button is changed.
|
|
606
|
+
|
|
607
|
+
Updates the visibility of the depth spinbox (only visible for "Limited" mode)
|
|
608
|
+
and saves the current settings.
|
|
609
|
+
"""
|
|
610
|
+
self._update_depth_spinbox_visibility()
|
|
611
|
+
self.save_settings()
|
|
612
|
+
|
|
613
|
+
def _on_recursion_depth_changed(self):
|
|
614
|
+
"""
|
|
615
|
+
Callback when recursion depth value is changed.
|
|
616
|
+
|
|
617
|
+
Saves the current settings to persist the user's depth preference.
|
|
618
|
+
"""
|
|
619
|
+
self.save_settings()
|
|
620
|
+
|
|
621
|
+
def _update_depth_spinbox_visibility(self):
|
|
622
|
+
"""
|
|
623
|
+
Update the visibility of the depth spinbox based on recursion mode.
|
|
624
|
+
|
|
625
|
+
The depth spinbox is only visible when "Limited" recursion mode is selected.
|
|
626
|
+
For "None" and "Full" modes, the spinbox is hidden.
|
|
627
|
+
"""
|
|
628
|
+
if self.recursion_mode.get() == "limited":
|
|
629
|
+
self.depth_spinbox.config(state='normal')
|
|
630
|
+
else:
|
|
631
|
+
self.depth_spinbox.config(state='disabled')
|
|
632
|
+
|
|
633
|
+
def _create_size_format_controls(self):
|
|
634
|
+
"""
|
|
635
|
+
Create size format selection controls in the right column.
|
|
636
|
+
|
|
637
|
+
Creates radio buttons for "Bytes" and "Human Readable" size formats.
|
|
638
|
+
The control is bound to the size_format StringVar.
|
|
639
|
+
Settings are saved when the value changes.
|
|
640
|
+
"""
|
|
641
|
+
# Size Format section label
|
|
642
|
+
size_format_label = ttk.Label(
|
|
643
|
+
self.right_column,
|
|
644
|
+
text="Size Format:",
|
|
645
|
+
font=('TkDefaultFont', 9, 'bold')
|
|
646
|
+
)
|
|
647
|
+
size_format_label.grid(row=5, column=0, sticky='w', pady=(0, 5))
|
|
648
|
+
|
|
649
|
+
# Create frame to contain radio buttons
|
|
650
|
+
size_format_frame = ttk.Frame(self.right_column)
|
|
651
|
+
size_format_frame.grid(row=6, column=0, sticky='w', pady=(0, 15))
|
|
652
|
+
|
|
653
|
+
# Radio button for "Bytes" format
|
|
654
|
+
bytes_radio = ttk.Radiobutton(
|
|
655
|
+
size_format_frame,
|
|
656
|
+
text="Bytes",
|
|
657
|
+
variable=self.size_format,
|
|
658
|
+
value="bytes",
|
|
659
|
+
command=self._on_size_format_changed
|
|
660
|
+
)
|
|
661
|
+
bytes_radio.grid(row=0, column=0, sticky='w', pady=2)
|
|
662
|
+
|
|
663
|
+
# Radio button for "Human Readable" format
|
|
664
|
+
human_radio = ttk.Radiobutton(
|
|
665
|
+
size_format_frame,
|
|
666
|
+
text="Human Readable",
|
|
667
|
+
variable=self.size_format,
|
|
668
|
+
value="human",
|
|
669
|
+
command=self._on_size_format_changed
|
|
670
|
+
)
|
|
671
|
+
human_radio.grid(row=1, column=0, sticky='w', pady=2)
|
|
672
|
+
|
|
673
|
+
def _on_size_format_changed(self):
|
|
674
|
+
"""
|
|
675
|
+
Callback when size format radio button is changed.
|
|
676
|
+
|
|
677
|
+
Saves the current settings to persist the user's size format preference.
|
|
678
|
+
"""
|
|
679
|
+
self.save_settings()
|
|
680
|
+
|
|
681
|
+
def _create_process_button(self, parent):
|
|
682
|
+
"""
|
|
683
|
+
Create the Process button below the options panel.
|
|
684
|
+
|
|
685
|
+
The Process button triggers report generation after validating that:
|
|
686
|
+
- At least one information field is selected
|
|
687
|
+
- At least one folder is selected
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
parent: Parent frame to contain the button
|
|
691
|
+
"""
|
|
692
|
+
# Create frame for button (centered)
|
|
693
|
+
button_frame = ttk.Frame(parent)
|
|
694
|
+
button_frame.pack(fill='x', pady=(0, 10))
|
|
695
|
+
|
|
696
|
+
# Create Process button
|
|
697
|
+
self.process_button = ttk.Button(
|
|
698
|
+
button_frame,
|
|
699
|
+
text="Process",
|
|
700
|
+
command=self._on_process_clicked,
|
|
701
|
+
width=20
|
|
702
|
+
)
|
|
703
|
+
self.process_button.pack(pady=5)
|
|
704
|
+
|
|
705
|
+
def _on_process_clicked(self):
|
|
706
|
+
"""
|
|
707
|
+
Handle Process button click event.
|
|
708
|
+
|
|
709
|
+
Validates user selections and initiates report generation if validation passes.
|
|
710
|
+
Displays warning messages if validation fails.
|
|
711
|
+
|
|
712
|
+
Validation checks:
|
|
713
|
+
1. At least one information field must be selected
|
|
714
|
+
2. At least one folder must be selected
|
|
715
|
+
|
|
716
|
+
Requirements: 1.4, 2.3
|
|
717
|
+
"""
|
|
718
|
+
# Validate that at least one field is selected
|
|
719
|
+
if not self._validate_field_selection():
|
|
720
|
+
self._show_warning(
|
|
721
|
+
"No Fields Selected",
|
|
722
|
+
"Please select at least one information field to include in the report."
|
|
723
|
+
)
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
# Validate that at least one folder is selected
|
|
727
|
+
if not self._validate_folder_selection():
|
|
728
|
+
self._show_warning(
|
|
729
|
+
"No Folders Selected",
|
|
730
|
+
"Please select at least one folder (Input or Output) to generate a report."
|
|
731
|
+
)
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
# Validation passed - proceed with report generation
|
|
735
|
+
self.generate_report()
|
|
736
|
+
|
|
737
|
+
def _validate_field_selection(self):
|
|
738
|
+
"""
|
|
739
|
+
Validate that at least one information field is selected.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
bool: True if at least one field is selected, False otherwise
|
|
743
|
+
"""
|
|
744
|
+
# Check if any field checkbox is checked
|
|
745
|
+
for field_var in self.field_selections.values():
|
|
746
|
+
if field_var.get():
|
|
747
|
+
return True
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
def _validate_folder_selection(self):
|
|
751
|
+
"""
|
|
752
|
+
Validate that at least one folder is selected.
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
bool: True if at least one folder path is set, False otherwise
|
|
756
|
+
"""
|
|
757
|
+
# Check if either input or output folder is selected
|
|
758
|
+
input_folder = self.input_folder_path.get().strip()
|
|
759
|
+
output_folder = self.output_folder_path.get().strip()
|
|
760
|
+
|
|
761
|
+
return bool(input_folder or output_folder)
|
|
762
|
+
|
|
763
|
+
def generate_report(self):
|
|
764
|
+
"""
|
|
765
|
+
Generate reports for selected folders and display results in respective tabs.
|
|
766
|
+
|
|
767
|
+
Orchestrates the scanning and formatting process:
|
|
768
|
+
1. Processes Input folder and writes results to Input tab text widget
|
|
769
|
+
2. Processes Output folder and writes results to Output tab text widget
|
|
770
|
+
3. Handles case where only one folder is selected
|
|
771
|
+
4. Displays each item on a separate line
|
|
772
|
+
5. Adds summary line with total item count at the end
|
|
773
|
+
6. Clears existing text before inserting new report
|
|
774
|
+
|
|
775
|
+
Requirements: 1.4, 1.5, 6.1, 6.2, 6.3, 6.4, 6.5
|
|
776
|
+
"""
|
|
777
|
+
# Get folder paths
|
|
778
|
+
input_folder = self.input_folder_path.get().strip()
|
|
779
|
+
output_folder = self.output_folder_path.get().strip()
|
|
780
|
+
|
|
781
|
+
# Process Input folder if selected
|
|
782
|
+
if input_folder:
|
|
783
|
+
self._generate_report_for_folder(input_folder, self.input_text, "Input")
|
|
784
|
+
|
|
785
|
+
# Process Output folder if selected
|
|
786
|
+
if output_folder:
|
|
787
|
+
self._generate_report_for_folder(output_folder, self.output_text, "Output")
|
|
788
|
+
|
|
789
|
+
def _generate_report_for_folder(self, folder_path, text_widget, tab_name):
|
|
790
|
+
"""
|
|
791
|
+
Generate report for a single folder and display in the specified text widget.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
folder_path: Path to the folder to scan
|
|
795
|
+
text_widget: Text widget to display the report
|
|
796
|
+
tab_name: Name of the tab (for error messages)
|
|
797
|
+
|
|
798
|
+
Requirements: 6.1, 6.2, 6.4, 6.5, 7.5, 8.1, 8.2, 8.3, 8.4, 8.5
|
|
799
|
+
"""
|
|
800
|
+
# Normalize the folder path to use consistent separators
|
|
801
|
+
folder_path = os.path.normpath(folder_path)
|
|
802
|
+
|
|
803
|
+
# Clear existing text before inserting new report
|
|
804
|
+
text_widget.delete('1.0', tk.END)
|
|
805
|
+
|
|
806
|
+
# Track errors encountered during processing
|
|
807
|
+
errors_encountered = []
|
|
808
|
+
|
|
809
|
+
# Validate folder exists (Requirement 8.1)
|
|
810
|
+
if not os.path.exists(folder_path):
|
|
811
|
+
error_msg = f"Error: Folder does not exist: {folder_path}"
|
|
812
|
+
text_widget.insert('1.0', error_msg)
|
|
813
|
+
self._show_error("Folder Not Found", f"The selected {tab_name} folder does not exist:\n{folder_path}")
|
|
814
|
+
self._log_error(f"Folder not found: {folder_path}")
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
# Validate it's a directory
|
|
818
|
+
if not os.path.isdir(folder_path):
|
|
819
|
+
error_msg = f"Error: Path is not a directory: {folder_path}"
|
|
820
|
+
text_widget.insert('1.0', error_msg)
|
|
821
|
+
self._show_error("Invalid Path", f"The selected {tab_name} path is not a directory:\n{folder_path}")
|
|
822
|
+
self._log_error(f"Invalid path (not a directory): {folder_path}")
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
# Check permissions (Requirement 8.2)
|
|
826
|
+
if not os.access(folder_path, os.R_OK):
|
|
827
|
+
error_msg = f"Error: Permission denied accessing folder: {folder_path}"
|
|
828
|
+
text_widget.insert('1.0', error_msg)
|
|
829
|
+
self._show_error("Permission Denied", f"You do not have permission to access the {tab_name} folder:\n{folder_path}")
|
|
830
|
+
self._log_error(f"Permission denied: {folder_path}")
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
# Show progress indicator for large directories (Requirement 8.3)
|
|
835
|
+
text_widget.insert('1.0', f"Scanning {tab_name} folder...\nPlease wait...\n")
|
|
836
|
+
text_widget.update_idletasks()
|
|
837
|
+
|
|
838
|
+
# Initialize progress tracking
|
|
839
|
+
progress_info = {
|
|
840
|
+
'count': 0,
|
|
841
|
+
'text_widget': text_widget,
|
|
842
|
+
'tab_name': tab_name,
|
|
843
|
+
'last_update': 0
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
# Scan directory to collect file information
|
|
847
|
+
# Pass errors_encountered list to collect errors during scan
|
|
848
|
+
# Pass progress_info to enable progress tracking
|
|
849
|
+
items = self._scan_directory(folder_path, errors_list=errors_encountered, progress_info=progress_info)
|
|
850
|
+
|
|
851
|
+
# Clear the progress message
|
|
852
|
+
text_widget.delete('1.0', tk.END)
|
|
853
|
+
|
|
854
|
+
# Format and display each item on a separate line
|
|
855
|
+
report_lines = []
|
|
856
|
+
for item in items:
|
|
857
|
+
try:
|
|
858
|
+
# Format the report line for this item
|
|
859
|
+
line = self._format_report_line(item)
|
|
860
|
+
report_lines.append(line)
|
|
861
|
+
except Exception as e:
|
|
862
|
+
# Log formatting error but continue processing (Requirement 8.5)
|
|
863
|
+
error_msg = f"Error formatting item {item.full_path}: {e}"
|
|
864
|
+
errors_encountered.append(error_msg)
|
|
865
|
+
self._log_error(error_msg)
|
|
866
|
+
|
|
867
|
+
# Join all lines with newlines
|
|
868
|
+
report_text = '\n'.join(report_lines)
|
|
869
|
+
|
|
870
|
+
# Add error summary if errors were encountered (Requirement 8.5)
|
|
871
|
+
if errors_encountered:
|
|
872
|
+
error_summary = f"\n\n--- Errors Encountered ({len(errors_encountered)}) ---\n"
|
|
873
|
+
error_summary += '\n'.join(f" • {err}" for err in errors_encountered[:10]) # Show first 10 errors
|
|
874
|
+
if len(errors_encountered) > 10:
|
|
875
|
+
error_summary += f"\n ... and {len(errors_encountered) - 10} more errors"
|
|
876
|
+
report_text += error_summary
|
|
877
|
+
|
|
878
|
+
# Add summary line with total item count at the end (Requirement 8.4)
|
|
879
|
+
summary_line = f"\n\n--- Report Complete: {len(items):,} items processed ---"
|
|
880
|
+
report_text += summary_line
|
|
881
|
+
|
|
882
|
+
# Insert report into text widget
|
|
883
|
+
text_widget.insert('1.0', report_text)
|
|
884
|
+
|
|
885
|
+
# Display success message with item count (Requirement 8.4)
|
|
886
|
+
success_msg = f"{tab_name} folder report generated successfully.\n{len(items):,} items processed."
|
|
887
|
+
if errors_encountered:
|
|
888
|
+
success_msg += f"\n{len(errors_encountered)} errors encountered (see report for details)."
|
|
889
|
+
self._show_info("Report Generated", success_msg)
|
|
890
|
+
|
|
891
|
+
except PermissionError as e:
|
|
892
|
+
# Handle permission errors (Requirement 8.2)
|
|
893
|
+
error_msg = f"Error: Permission denied during scan: {e}"
|
|
894
|
+
text_widget.delete('1.0', tk.END)
|
|
895
|
+
text_widget.insert('1.0', error_msg)
|
|
896
|
+
self._show_error("Permission Error", f"Permission error while scanning {tab_name} folder:\n{e}")
|
|
897
|
+
self._log_error(f"Permission error during scan: {e}")
|
|
898
|
+
|
|
899
|
+
except FileNotFoundError as e:
|
|
900
|
+
# Handle file not found errors (Requirement 7.5)
|
|
901
|
+
error_msg = f"Error: File or folder not found during scan: {e}"
|
|
902
|
+
text_widget.delete('1.0', tk.END)
|
|
903
|
+
text_widget.insert('1.0', error_msg)
|
|
904
|
+
self._show_error("File Not Found", f"File or folder not found while scanning {tab_name} folder:\n{e}")
|
|
905
|
+
self._log_error(f"File not found during scan: {e}")
|
|
906
|
+
|
|
907
|
+
except OSError as e:
|
|
908
|
+
# Handle OS errors (Requirement 8.5)
|
|
909
|
+
error_msg = f"Error: OS error during scan: {e}"
|
|
910
|
+
text_widget.delete('1.0', tk.END)
|
|
911
|
+
text_widget.insert('1.0', error_msg)
|
|
912
|
+
self._show_error("File System Error", f"Error accessing {tab_name} folder:\n{e}")
|
|
913
|
+
self._log_error(f"OS error during scan: {e}")
|
|
914
|
+
|
|
915
|
+
except Exception as e:
|
|
916
|
+
# Handle unexpected errors (Requirement 8.5)
|
|
917
|
+
error_msg = f"Error: Unexpected error during scan: {e}"
|
|
918
|
+
text_widget.delete('1.0', tk.END)
|
|
919
|
+
text_widget.insert('1.0', error_msg)
|
|
920
|
+
self._show_error("Unexpected Error", f"An unexpected error occurred while processing {tab_name} folder:\n{e}")
|
|
921
|
+
self._log_error(f"Unexpected error during scan: {e}")
|
|
922
|
+
|
|
923
|
+
def _show_warning(self, title, message):
|
|
924
|
+
"""
|
|
925
|
+
Display a warning message to the user.
|
|
926
|
+
|
|
927
|
+
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
title: Warning dialog title
|
|
931
|
+
message: Warning message text
|
|
932
|
+
"""
|
|
933
|
+
if self.dialog_manager and hasattr(self.dialog_manager, 'show_warning'):
|
|
934
|
+
self.dialog_manager.show_warning(title, message, parent=self.parent)
|
|
935
|
+
else:
|
|
936
|
+
messagebox.showwarning(title, message, parent=self.parent)
|
|
937
|
+
|
|
938
|
+
def _show_info(self, title, message):
|
|
939
|
+
"""
|
|
940
|
+
Display an information message to the user.
|
|
941
|
+
|
|
942
|
+
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
title: Information dialog title
|
|
946
|
+
message: Information message text
|
|
947
|
+
"""
|
|
948
|
+
if self.dialog_manager and hasattr(self.dialog_manager, 'show_info'):
|
|
949
|
+
self.dialog_manager.show_info(title, message, parent=self.parent)
|
|
950
|
+
else:
|
|
951
|
+
messagebox.showinfo(title, message, parent=self.parent)
|
|
952
|
+
|
|
953
|
+
def _format_report_line(self, file_info):
|
|
954
|
+
"""
|
|
955
|
+
Format a single line of the report based on selected fields.
|
|
956
|
+
|
|
957
|
+
Builds output lines by including only the selected information fields
|
|
958
|
+
in the specified order: path, name, size, date_modified.
|
|
959
|
+
Processes separator string with escape sequences and formats values
|
|
960
|
+
according to user preferences.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
file_info: FileInfo namedtuple containing file/folder information
|
|
964
|
+
|
|
965
|
+
Returns:
|
|
966
|
+
str: Formatted report line with selected fields separated by the configured separator
|
|
967
|
+
|
|
968
|
+
Requirements: 2.2, 2.4, 2.5, 3.2, 3.4, 3.5, 7.1, 7.2, 7.3, 7.4
|
|
969
|
+
"""
|
|
970
|
+
# Collect selected field values in the correct order
|
|
971
|
+
field_values = []
|
|
972
|
+
|
|
973
|
+
# Field ordering: path, name, size, date_modified
|
|
974
|
+
|
|
975
|
+
# 1. Path field
|
|
976
|
+
if self.field_selections['path'].get():
|
|
977
|
+
field_values.append(file_info.full_path)
|
|
978
|
+
|
|
979
|
+
# 2. Name field
|
|
980
|
+
if self.field_selections['name'].get():
|
|
981
|
+
field_values.append(file_info.name)
|
|
982
|
+
|
|
983
|
+
# 3. Size field
|
|
984
|
+
if self.field_selections['size'].get():
|
|
985
|
+
formatted_size = self._format_size(file_info.size, file_info.is_folder)
|
|
986
|
+
field_values.append(formatted_size)
|
|
987
|
+
|
|
988
|
+
# 4. Date Modified field
|
|
989
|
+
if self.field_selections['date_modified'].get():
|
|
990
|
+
formatted_date = self._format_date(file_info.modified_time)
|
|
991
|
+
field_values.append(formatted_date)
|
|
992
|
+
|
|
993
|
+
# Process separator string with escape sequences
|
|
994
|
+
separator = self._process_separator()
|
|
995
|
+
|
|
996
|
+
# Join field values with the separator
|
|
997
|
+
return separator.join(field_values)
|
|
998
|
+
|
|
999
|
+
def _format_size(self, size_bytes, is_folder):
|
|
1000
|
+
"""
|
|
1001
|
+
Format file size according to the selected size format.
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
size_bytes: Size in bytes (int)
|
|
1005
|
+
is_folder: Whether the item is a folder (bool)
|
|
1006
|
+
|
|
1007
|
+
Returns:
|
|
1008
|
+
str: Formatted size string
|
|
1009
|
+
|
|
1010
|
+
Requirements: 7.3
|
|
1011
|
+
"""
|
|
1012
|
+
# Folders typically show 0 or a special indicator
|
|
1013
|
+
if is_folder:
|
|
1014
|
+
if self.size_format.get() == "bytes":
|
|
1015
|
+
return "0"
|
|
1016
|
+
else:
|
|
1017
|
+
return "<DIR>"
|
|
1018
|
+
|
|
1019
|
+
# Format based on selected size format
|
|
1020
|
+
if self.size_format.get() == "bytes":
|
|
1021
|
+
# Return size in bytes as a string
|
|
1022
|
+
return str(size_bytes)
|
|
1023
|
+
else:
|
|
1024
|
+
# Human-readable format (KB, MB, GB, TB)
|
|
1025
|
+
return self._format_size_human_readable(size_bytes)
|
|
1026
|
+
|
|
1027
|
+
def _format_size_human_readable(self, size_bytes):
|
|
1028
|
+
"""
|
|
1029
|
+
Format size in human-readable format with appropriate units.
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
size_bytes: Size in bytes (int)
|
|
1033
|
+
|
|
1034
|
+
Returns:
|
|
1035
|
+
str: Human-readable size string (e.g., "1.18 MB", "523 KB")
|
|
1036
|
+
|
|
1037
|
+
Requirements: 7.3
|
|
1038
|
+
"""
|
|
1039
|
+
# Define size units and thresholds
|
|
1040
|
+
units = [
|
|
1041
|
+
('TB', 1024**4),
|
|
1042
|
+
('GB', 1024**3),
|
|
1043
|
+
('MB', 1024**2),
|
|
1044
|
+
('KB', 1024)
|
|
1045
|
+
]
|
|
1046
|
+
|
|
1047
|
+
# Handle zero or very small sizes
|
|
1048
|
+
if size_bytes == 0:
|
|
1049
|
+
return "0 bytes"
|
|
1050
|
+
|
|
1051
|
+
if size_bytes < 1024:
|
|
1052
|
+
return f"{size_bytes} bytes"
|
|
1053
|
+
|
|
1054
|
+
# Find appropriate unit
|
|
1055
|
+
for unit_name, unit_size in units:
|
|
1056
|
+
if size_bytes >= unit_size:
|
|
1057
|
+
size_value = size_bytes / unit_size
|
|
1058
|
+
# Format with 2 decimal places, but remove trailing zeros
|
|
1059
|
+
formatted = f"{size_value:.2f}".rstrip('0').rstrip('.')
|
|
1060
|
+
return f"{formatted} {unit_name}"
|
|
1061
|
+
|
|
1062
|
+
# Fallback (should not reach here)
|
|
1063
|
+
return f"{size_bytes} bytes"
|
|
1064
|
+
|
|
1065
|
+
def _format_date(self, timestamp):
|
|
1066
|
+
"""
|
|
1067
|
+
Format modification timestamp using the configured date format.
|
|
1068
|
+
|
|
1069
|
+
Args:
|
|
1070
|
+
timestamp: Unix timestamp (float)
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
str: Formatted date string
|
|
1074
|
+
|
|
1075
|
+
Requirements: 7.4
|
|
1076
|
+
"""
|
|
1077
|
+
try:
|
|
1078
|
+
# Convert timestamp to datetime object
|
|
1079
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
1080
|
+
|
|
1081
|
+
# Format using the configured format string
|
|
1082
|
+
# Default format: "%Y-%m-%d %H:%M:%S" (YYYY-MM-DD HH:MM:SS)
|
|
1083
|
+
date_format = self.date_format.get()
|
|
1084
|
+
return dt.strftime(date_format)
|
|
1085
|
+
|
|
1086
|
+
except (ValueError, OSError) as e:
|
|
1087
|
+
# Handle invalid timestamps
|
|
1088
|
+
return "Invalid Date"
|
|
1089
|
+
|
|
1090
|
+
def _process_separator(self):
|
|
1091
|
+
"""
|
|
1092
|
+
Process separator string to interpret escape sequences.
|
|
1093
|
+
|
|
1094
|
+
Converts common escape sequences to their actual characters:
|
|
1095
|
+
- \\t → tab character
|
|
1096
|
+
- \\n → newline character
|
|
1097
|
+
- \\\\ → backslash character
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
str: Processed separator string with escape sequences converted
|
|
1101
|
+
|
|
1102
|
+
Requirements: 3.2, 3.4
|
|
1103
|
+
"""
|
|
1104
|
+
separator = self.separator.get()
|
|
1105
|
+
|
|
1106
|
+
# Process escape sequences
|
|
1107
|
+
# Note: We need to be careful with the order of replacements
|
|
1108
|
+
# to avoid double-processing
|
|
1109
|
+
|
|
1110
|
+
# First, handle double backslash (\\) by replacing with a placeholder
|
|
1111
|
+
separator = separator.replace('\\\\', '\x00')
|
|
1112
|
+
|
|
1113
|
+
# Then handle \t and \n
|
|
1114
|
+
separator = separator.replace('\\t', '\t')
|
|
1115
|
+
separator = separator.replace('\\n', '\n')
|
|
1116
|
+
|
|
1117
|
+
# Finally, restore the placeholder to a single backslash
|
|
1118
|
+
separator = separator.replace('\x00', '\\')
|
|
1119
|
+
|
|
1120
|
+
return separator
|
|
1121
|
+
|
|
1122
|
+
def _show_error(self, title, message):
|
|
1123
|
+
"""
|
|
1124
|
+
Display an error message to the user.
|
|
1125
|
+
|
|
1126
|
+
Uses dialog_manager if available, otherwise falls back to messagebox.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
title: Error dialog title
|
|
1130
|
+
message: Error message text
|
|
1131
|
+
|
|
1132
|
+
Requirements: 8.1, 8.2, 8.5
|
|
1133
|
+
"""
|
|
1134
|
+
if self.dialog_manager and hasattr(self.dialog_manager, 'show_error'):
|
|
1135
|
+
self.dialog_manager.show_error(title, message, parent=self.parent)
|
|
1136
|
+
else:
|
|
1137
|
+
messagebox.showerror(title, message, parent=self.parent)
|
|
1138
|
+
|
|
1139
|
+
def _log_error(self, error_message):
|
|
1140
|
+
"""
|
|
1141
|
+
Log error messages for debugging and troubleshooting.
|
|
1142
|
+
|
|
1143
|
+
Prints error messages to console. In a production environment,
|
|
1144
|
+
this could be extended to write to a log file.
|
|
1145
|
+
|
|
1146
|
+
Args:
|
|
1147
|
+
error_message: Error message to log
|
|
1148
|
+
|
|
1149
|
+
Requirements: 8.5
|
|
1150
|
+
"""
|
|
1151
|
+
print(f"[Folder File Reporter Error] {error_message}")
|
|
1152
|
+
|
|
1153
|
+
def _update_progress(self, progress_info):
|
|
1154
|
+
"""
|
|
1155
|
+
Update progress indicator during directory scanning.
|
|
1156
|
+
|
|
1157
|
+
Displays progress updates periodically for large directories (>1000 items).
|
|
1158
|
+
Updates are shown every 100 items to avoid excessive UI updates.
|
|
1159
|
+
|
|
1160
|
+
Args:
|
|
1161
|
+
progress_info: Dict containing:
|
|
1162
|
+
- count: Current number of items processed
|
|
1163
|
+
- text_widget: Text widget to display progress
|
|
1164
|
+
- tab_name: Name of the tab (for display)
|
|
1165
|
+
- last_update: Last count when progress was updated
|
|
1166
|
+
|
|
1167
|
+
Requirements: 8.3
|
|
1168
|
+
"""
|
|
1169
|
+
count = progress_info['count']
|
|
1170
|
+
last_update = progress_info['last_update']
|
|
1171
|
+
|
|
1172
|
+
# Only show progress for directories with >1000 items
|
|
1173
|
+
# Update every 100 items to avoid excessive UI updates
|
|
1174
|
+
if count > 1000 and (count - last_update) >= 100:
|
|
1175
|
+
text_widget = progress_info['text_widget']
|
|
1176
|
+
tab_name = progress_info['tab_name']
|
|
1177
|
+
|
|
1178
|
+
# Update the progress message
|
|
1179
|
+
text_widget.delete('1.0', tk.END)
|
|
1180
|
+
progress_msg = f"Scanning {tab_name} folder...\n"
|
|
1181
|
+
progress_msg += f"Items processed: {count:,}\n"
|
|
1182
|
+
progress_msg += "Please wait..."
|
|
1183
|
+
text_widget.insert('1.0', progress_msg)
|
|
1184
|
+
text_widget.update_idletasks()
|
|
1185
|
+
|
|
1186
|
+
# Update last_update counter
|
|
1187
|
+
progress_info['last_update'] = count
|
|
1188
|
+
|
|
1189
|
+
def _get_file_info(self, file_path):
|
|
1190
|
+
"""
|
|
1191
|
+
Get file metadata using os.stat().
|
|
1192
|
+
|
|
1193
|
+
Extracts file size in bytes, modification timestamp, and determines
|
|
1194
|
+
if the item is a file or folder. Handles permission errors and
|
|
1195
|
+
inaccessible files gracefully by logging errors and returning None.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
file_path: Path to the file or folder
|
|
1199
|
+
|
|
1200
|
+
Returns:
|
|
1201
|
+
FileInfo: Named tuple containing file metadata, or None if file is inaccessible
|
|
1202
|
+
- full_path: Complete path to file/folder
|
|
1203
|
+
- name: File/folder name
|
|
1204
|
+
- size: Size in bytes (0 for folders)
|
|
1205
|
+
- modified_time: Timestamp of last modification
|
|
1206
|
+
- is_folder: True if folder, False if file
|
|
1207
|
+
|
|
1208
|
+
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 8.2, 8.5
|
|
1209
|
+
"""
|
|
1210
|
+
try:
|
|
1211
|
+
# Normalize the file path to use consistent separators
|
|
1212
|
+
file_path = os.path.normpath(file_path)
|
|
1213
|
+
|
|
1214
|
+
# Get file statistics using os.stat()
|
|
1215
|
+
stat_info = os.stat(file_path)
|
|
1216
|
+
|
|
1217
|
+
# Extract file name from path
|
|
1218
|
+
file_name = os.path.basename(file_path)
|
|
1219
|
+
|
|
1220
|
+
# Determine if item is a file or folder
|
|
1221
|
+
is_folder = os.path.isdir(file_path)
|
|
1222
|
+
|
|
1223
|
+
# Extract file size in bytes (0 for folders)
|
|
1224
|
+
# For folders, we report 0 as the size (not the sum of contents)
|
|
1225
|
+
file_size = 0 if is_folder else stat_info.st_size
|
|
1226
|
+
|
|
1227
|
+
# Extract modification timestamp
|
|
1228
|
+
modified_time = stat_info.st_mtime
|
|
1229
|
+
|
|
1230
|
+
# Create and return FileInfo named tuple
|
|
1231
|
+
return FileInfo(
|
|
1232
|
+
full_path=file_path,
|
|
1233
|
+
name=file_name,
|
|
1234
|
+
size=file_size,
|
|
1235
|
+
modified_time=modified_time,
|
|
1236
|
+
is_folder=is_folder
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
except PermissionError as e:
|
|
1240
|
+
# Handle permission errors - log and return None (Requirement 8.2, 7.5)
|
|
1241
|
+
error_msg = f"Permission denied accessing: {file_path} - {e}"
|
|
1242
|
+
self._log_error(error_msg)
|
|
1243
|
+
return None
|
|
1244
|
+
|
|
1245
|
+
except FileNotFoundError as e:
|
|
1246
|
+
# Handle files that were deleted during scan - log and return None (Requirement 7.5)
|
|
1247
|
+
error_msg = f"File not found (may have been deleted): {file_path} - {e}"
|
|
1248
|
+
self._log_error(error_msg)
|
|
1249
|
+
return None
|
|
1250
|
+
|
|
1251
|
+
except OSError as e:
|
|
1252
|
+
# Handle other OS errors (symbolic link issues, etc.) - log and return None (Requirement 8.5)
|
|
1253
|
+
error_msg = f"OS error accessing: {file_path} - {e}"
|
|
1254
|
+
self._log_error(error_msg)
|
|
1255
|
+
return None
|
|
1256
|
+
|
|
1257
|
+
except Exception as e:
|
|
1258
|
+
# Catch any other unexpected errors - log and return None (Requirement 8.5)
|
|
1259
|
+
error_msg = f"Unexpected error accessing: {file_path} - {e}"
|
|
1260
|
+
self._log_error(error_msg)
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
def _scan_directory(self, folder_path, current_depth=0, visited_paths=None, errors_list=None, progress_info=None):
|
|
1264
|
+
"""
|
|
1265
|
+
Recursively scan directory and collect file/folder information.
|
|
1266
|
+
|
|
1267
|
+
This method traverses the directory structure based on the configured recursion
|
|
1268
|
+
mode and depth settings. It handles symbolic links and circular references to
|
|
1269
|
+
prevent infinite loops, and applies the "Folders Only" filter during scanning.
|
|
1270
|
+
|
|
1271
|
+
Errors encountered during scanning are logged and added to the errors_list
|
|
1272
|
+
if provided, allowing processing to continue for remaining items.
|
|
1273
|
+
|
|
1274
|
+
Progress tracking is enabled when progress_info is provided, displaying
|
|
1275
|
+
periodic updates for large directories (>1000 items).
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
folder_path: Path to the directory to scan
|
|
1279
|
+
current_depth: Current recursion depth (0 = root folder)
|
|
1280
|
+
visited_paths: Set of visited real paths to detect circular references
|
|
1281
|
+
errors_list: Optional list to collect error messages encountered during scan
|
|
1282
|
+
progress_info: Optional dict with progress tracking info (count, text_widget, tab_name, last_update)
|
|
1283
|
+
|
|
1284
|
+
Returns:
|
|
1285
|
+
List of FileInfo namedtuples containing file/folder information
|
|
1286
|
+
|
|
1287
|
+
Requirements: 4.4, 5.2, 5.3, 5.4, 5.5, 5.6, 7.5, 8.2, 8.3, 8.5
|
|
1288
|
+
"""
|
|
1289
|
+
# Normalize the folder path to use consistent separators
|
|
1290
|
+
folder_path = os.path.normpath(folder_path)
|
|
1291
|
+
|
|
1292
|
+
# Initialize visited paths set on first call to track circular references
|
|
1293
|
+
if visited_paths is None:
|
|
1294
|
+
visited_paths = set()
|
|
1295
|
+
|
|
1296
|
+
# Get the real path to handle symbolic links (Requirement 5.6)
|
|
1297
|
+
try:
|
|
1298
|
+
real_path = os.path.realpath(folder_path)
|
|
1299
|
+
except (OSError, ValueError) as e:
|
|
1300
|
+
error_msg = f"Could not resolve real path for {folder_path}: {e}"
|
|
1301
|
+
self._log_error(error_msg)
|
|
1302
|
+
if errors_list is not None:
|
|
1303
|
+
errors_list.append(error_msg)
|
|
1304
|
+
return []
|
|
1305
|
+
|
|
1306
|
+
# Check for circular reference - if we've already visited this path, skip it (Requirement 5.6)
|
|
1307
|
+
if real_path in visited_paths:
|
|
1308
|
+
warning_msg = f"Circular reference detected at {folder_path}, skipping"
|
|
1309
|
+
self._log_error(warning_msg)
|
|
1310
|
+
if errors_list is not None:
|
|
1311
|
+
errors_list.append(warning_msg)
|
|
1312
|
+
return []
|
|
1313
|
+
|
|
1314
|
+
# Add current path to visited set
|
|
1315
|
+
visited_paths.add(real_path)
|
|
1316
|
+
|
|
1317
|
+
# List to collect file information
|
|
1318
|
+
items = []
|
|
1319
|
+
|
|
1320
|
+
# Check if folder exists and is accessible
|
|
1321
|
+
if not os.path.exists(folder_path):
|
|
1322
|
+
error_msg = f"Folder does not exist: {folder_path}"
|
|
1323
|
+
self._log_error(error_msg)
|
|
1324
|
+
if errors_list is not None:
|
|
1325
|
+
errors_list.append(error_msg)
|
|
1326
|
+
return []
|
|
1327
|
+
|
|
1328
|
+
if not os.path.isdir(folder_path):
|
|
1329
|
+
error_msg = f"Path is not a directory: {folder_path}"
|
|
1330
|
+
self._log_error(error_msg)
|
|
1331
|
+
if errors_list is not None:
|
|
1332
|
+
errors_list.append(error_msg)
|
|
1333
|
+
return []
|
|
1334
|
+
|
|
1335
|
+
# Get recursion settings
|
|
1336
|
+
recursion_mode = self.recursion_mode.get()
|
|
1337
|
+
max_depth = self.recursion_depth.get() if recursion_mode == "limited" else -1
|
|
1338
|
+
folders_only = self.folders_only.get()
|
|
1339
|
+
|
|
1340
|
+
# Determine if we should recurse into subdirectories
|
|
1341
|
+
should_recurse = False
|
|
1342
|
+
if recursion_mode == "full":
|
|
1343
|
+
should_recurse = True
|
|
1344
|
+
elif recursion_mode == "limited" and current_depth < max_depth:
|
|
1345
|
+
should_recurse = True
|
|
1346
|
+
# recursion_mode == "none" means should_recurse stays False
|
|
1347
|
+
|
|
1348
|
+
try:
|
|
1349
|
+
# Get list of items in the directory
|
|
1350
|
+
dir_entries = os.listdir(folder_path)
|
|
1351
|
+
except PermissionError as e:
|
|
1352
|
+
# Handle permission errors - log and continue (Requirement 8.2)
|
|
1353
|
+
error_msg = f"Permission denied accessing {folder_path}: {e}"
|
|
1354
|
+
self._log_error(error_msg)
|
|
1355
|
+
if errors_list is not None:
|
|
1356
|
+
errors_list.append(error_msg)
|
|
1357
|
+
return []
|
|
1358
|
+
except OSError as e:
|
|
1359
|
+
# Handle OS errors - log and continue (Requirement 8.5)
|
|
1360
|
+
error_msg = f"Error reading directory {folder_path}: {e}"
|
|
1361
|
+
self._log_error(error_msg)
|
|
1362
|
+
if errors_list is not None:
|
|
1363
|
+
errors_list.append(error_msg)
|
|
1364
|
+
return []
|
|
1365
|
+
|
|
1366
|
+
# Process each item in the directory
|
|
1367
|
+
for entry_name in dir_entries:
|
|
1368
|
+
# Construct full path and normalize separators
|
|
1369
|
+
entry_path = os.path.normpath(os.path.join(folder_path, entry_name))
|
|
1370
|
+
|
|
1371
|
+
try:
|
|
1372
|
+
# Get file information using os.stat()
|
|
1373
|
+
# Use lstat() to not follow symbolic links initially
|
|
1374
|
+
stat_info = os.lstat(entry_path)
|
|
1375
|
+
|
|
1376
|
+
# Check if this is a symbolic link
|
|
1377
|
+
is_symlink = os.path.islink(entry_path)
|
|
1378
|
+
|
|
1379
|
+
# Determine if this is a directory
|
|
1380
|
+
# For symlinks, check what they point to
|
|
1381
|
+
if is_symlink:
|
|
1382
|
+
try:
|
|
1383
|
+
# Follow the symlink to see if it points to a directory
|
|
1384
|
+
is_dir = os.path.isdir(entry_path)
|
|
1385
|
+
except (OSError, ValueError) as e:
|
|
1386
|
+
# Broken symlink or permission error
|
|
1387
|
+
error_msg = f"Error following symlink {entry_path}: {e}"
|
|
1388
|
+
self._log_error(error_msg)
|
|
1389
|
+
if errors_list is not None:
|
|
1390
|
+
errors_list.append(error_msg)
|
|
1391
|
+
is_dir = False
|
|
1392
|
+
else:
|
|
1393
|
+
is_dir = os.path.isdir(entry_path)
|
|
1394
|
+
|
|
1395
|
+
# Apply "Folders Only" filter
|
|
1396
|
+
if folders_only and not is_dir:
|
|
1397
|
+
continue # Skip files when folders_only is enabled
|
|
1398
|
+
|
|
1399
|
+
# Get file size (0 for directories)
|
|
1400
|
+
size = 0 if is_dir else stat_info.st_size
|
|
1401
|
+
|
|
1402
|
+
# Get modification time
|
|
1403
|
+
modified_time = stat_info.st_mtime
|
|
1404
|
+
|
|
1405
|
+
# Create FileInfo tuple
|
|
1406
|
+
file_info = FileInfo(
|
|
1407
|
+
full_path=entry_path,
|
|
1408
|
+
name=entry_name,
|
|
1409
|
+
size=size,
|
|
1410
|
+
modified_time=modified_time,
|
|
1411
|
+
is_folder=is_dir
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
# Add to items list
|
|
1415
|
+
items.append(file_info)
|
|
1416
|
+
|
|
1417
|
+
# Update progress tracking if enabled (Requirement 8.3)
|
|
1418
|
+
if progress_info is not None:
|
|
1419
|
+
progress_info['count'] += 1
|
|
1420
|
+
self._update_progress(progress_info)
|
|
1421
|
+
|
|
1422
|
+
# Recursively scan subdirectories if appropriate
|
|
1423
|
+
if is_dir and should_recurse:
|
|
1424
|
+
# Recursively scan the subdirectory
|
|
1425
|
+
sub_items = self._scan_directory(
|
|
1426
|
+
entry_path,
|
|
1427
|
+
current_depth=current_depth + 1,
|
|
1428
|
+
visited_paths=visited_paths,
|
|
1429
|
+
errors_list=errors_list,
|
|
1430
|
+
progress_info=progress_info
|
|
1431
|
+
)
|
|
1432
|
+
items.extend(sub_items)
|
|
1433
|
+
|
|
1434
|
+
except PermissionError as e:
|
|
1435
|
+
# Handle permission errors - log and continue (Requirement 8.2, 7.5)
|
|
1436
|
+
error_msg = f"Permission denied accessing {entry_path}: {e}"
|
|
1437
|
+
self._log_error(error_msg)
|
|
1438
|
+
if errors_list is not None:
|
|
1439
|
+
errors_list.append(error_msg)
|
|
1440
|
+
continue
|
|
1441
|
+
except FileNotFoundError as e:
|
|
1442
|
+
# Handle files deleted during scan - log and continue (Requirement 7.5)
|
|
1443
|
+
error_msg = f"File not found (may have been deleted): {entry_path}: {e}"
|
|
1444
|
+
self._log_error(error_msg)
|
|
1445
|
+
if errors_list is not None:
|
|
1446
|
+
errors_list.append(error_msg)
|
|
1447
|
+
continue
|
|
1448
|
+
except OSError as e:
|
|
1449
|
+
# Handle other OS errors - log and continue (Requirement 8.5)
|
|
1450
|
+
error_msg = f"Error accessing {entry_path}: {e}"
|
|
1451
|
+
self._log_error(error_msg)
|
|
1452
|
+
if errors_list is not None:
|
|
1453
|
+
errors_list.append(error_msg)
|
|
1454
|
+
continue
|
|
1455
|
+
except Exception as e:
|
|
1456
|
+
# Catch unexpected errors - log and continue (Requirement 8.5)
|
|
1457
|
+
error_msg = f"Unexpected error accessing {entry_path}: {e}"
|
|
1458
|
+
self._log_error(error_msg)
|
|
1459
|
+
if errors_list is not None:
|
|
1460
|
+
errors_list.append(error_msg)
|
|
1461
|
+
continue
|
|
1462
|
+
|
|
1463
|
+
return items
|