pomera-ai-commander 0.1.0

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.
Files changed (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +680 -0
  3. package/bin/pomera-ai-commander.js +62 -0
  4. package/core/__init__.py +66 -0
  5. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  7. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  8. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  9. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  10. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  11. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  12. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  13. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  14. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  15. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  16. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  17. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  18. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  19. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  20. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  21. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  22. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  23. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  24. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  25. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  26. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  27. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  28. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  29. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  30. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  31. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  32. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  33. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  34. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  35. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  36. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  37. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  38. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  39. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  40. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  41. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  42. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  43. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  44. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  45. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  46. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  47. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  48. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  49. package/core/app_context.py +482 -0
  50. package/core/async_text_processor.py +422 -0
  51. package/core/backup_manager.py +656 -0
  52. package/core/backup_recovery_manager.py +1034 -0
  53. package/core/content_hash_cache.py +509 -0
  54. package/core/context_menu.py +313 -0
  55. package/core/data_validator.py +1067 -0
  56. package/core/database_connection_manager.py +745 -0
  57. package/core/database_curl_settings_manager.py +609 -0
  58. package/core/database_promera_ai_settings_manager.py +447 -0
  59. package/core/database_schema.py +412 -0
  60. package/core/database_schema_manager.py +396 -0
  61. package/core/database_settings_manager.py +1508 -0
  62. package/core/database_settings_manager_interface.py +457 -0
  63. package/core/dialog_manager.py +735 -0
  64. package/core/efficient_line_numbers.py +511 -0
  65. package/core/error_handler.py +747 -0
  66. package/core/error_service.py +431 -0
  67. package/core/event_consolidator.py +512 -0
  68. package/core/mcp/__init__.py +43 -0
  69. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  71. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  72. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  73. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  74. package/core/mcp/protocol.py +288 -0
  75. package/core/mcp/schema.py +251 -0
  76. package/core/mcp/server_stdio.py +299 -0
  77. package/core/mcp/tool_registry.py +2345 -0
  78. package/core/memory_efficient_text_widget.py +712 -0
  79. package/core/migration_manager.py +915 -0
  80. package/core/migration_test_suite.py +1086 -0
  81. package/core/migration_validator.py +1144 -0
  82. package/core/optimized_find_replace.py +715 -0
  83. package/core/optimized_pattern_engine.py +424 -0
  84. package/core/optimized_search_highlighter.py +553 -0
  85. package/core/performance_monitor.py +675 -0
  86. package/core/persistence_manager.py +713 -0
  87. package/core/progressive_stats_calculator.py +632 -0
  88. package/core/regex_pattern_cache.py +530 -0
  89. package/core/regex_pattern_library.py +351 -0
  90. package/core/search_operation_manager.py +435 -0
  91. package/core/settings_defaults_registry.py +1087 -0
  92. package/core/settings_integrity_validator.py +1112 -0
  93. package/core/settings_serializer.py +558 -0
  94. package/core/settings_validator.py +1824 -0
  95. package/core/smart_stats_calculator.py +710 -0
  96. package/core/statistics_update_manager.py +619 -0
  97. package/core/stats_config_manager.py +858 -0
  98. package/core/streaming_text_handler.py +723 -0
  99. package/core/task_scheduler.py +596 -0
  100. package/core/update_pattern_library.py +169 -0
  101. package/core/visibility_monitor.py +596 -0
  102. package/core/widget_cache.py +498 -0
  103. package/mcp.json +61 -0
  104. package/package.json +57 -0
  105. package/pomera.py +7483 -0
  106. package/pomera_mcp_server.py +144 -0
  107. package/tools/__init__.py +5 -0
  108. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  110. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  111. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  112. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  113. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  114. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  115. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  116. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  117. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  118. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  119. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  120. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  121. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  122. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  123. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  124. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  125. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  126. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  127. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  128. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  129. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  130. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  131. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  132. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  133. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  134. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  135. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  136. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  137. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  138. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  139. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  140. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  141. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  142. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  143. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  144. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  145. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  146. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  147. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  148. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  149. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  150. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
  151. package/tools/ai_tools.py +2892 -0
  152. package/tools/ascii_art_generator.py +353 -0
  153. package/tools/base64_tools.py +184 -0
  154. package/tools/base_tool.py +511 -0
  155. package/tools/case_tool.py +309 -0
  156. package/tools/column_tools.py +396 -0
  157. package/tools/cron_tool.py +885 -0
  158. package/tools/curl_history.py +601 -0
  159. package/tools/curl_processor.py +1208 -0
  160. package/tools/curl_settings.py +503 -0
  161. package/tools/curl_tool.py +5467 -0
  162. package/tools/diff_viewer.py +1072 -0
  163. package/tools/email_extraction_tool.py +249 -0
  164. package/tools/email_header_analyzer.py +426 -0
  165. package/tools/extraction_tools.py +250 -0
  166. package/tools/find_replace.py +1751 -0
  167. package/tools/folder_file_reporter.py +1463 -0
  168. package/tools/folder_file_reporter_adapter.py +480 -0
  169. package/tools/generator_tools.py +1217 -0
  170. package/tools/hash_generator.py +256 -0
  171. package/tools/html_tool.py +657 -0
  172. package/tools/huggingface_helper.py +449 -0
  173. package/tools/jsonxml_tool.py +730 -0
  174. package/tools/line_tools.py +419 -0
  175. package/tools/list_comparator.py +720 -0
  176. package/tools/markdown_tools.py +562 -0
  177. package/tools/mcp_widget.py +1417 -0
  178. package/tools/notes_widget.py +973 -0
  179. package/tools/number_base_converter.py +373 -0
  180. package/tools/regex_extractor.py +572 -0
  181. package/tools/slug_generator.py +311 -0
  182. package/tools/sorter_tools.py +459 -0
  183. package/tools/string_escape_tool.py +393 -0
  184. package/tools/text_statistics_tool.py +366 -0
  185. package/tools/text_wrapper.py +431 -0
  186. package/tools/timestamp_converter.py +422 -0
  187. package/tools/tool_loader.py +710 -0
  188. package/tools/translator_tools.py +523 -0
  189. package/tools/url_link_extractor.py +262 -0
  190. package/tools/url_parser.py +205 -0
  191. package/tools/whitespace_tools.py +356 -0
  192. package/tools/word_frequency_counter.py +147 -0
@@ -0,0 +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