tdsearch 0.1.1__tar.gz
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.
- tdsearch-0.1.1/LICENSE +21 -0
- tdsearch-0.1.1/PKG-INFO +46 -0
- tdsearch-0.1.1/README.md +20 -0
- tdsearch-0.1.1/pyproject.toml +3 -0
- tdsearch-0.1.1/setup.cfg +41 -0
- tdsearch-0.1.1/setup.py +5 -0
- tdsearch-0.1.1/tdsearch/__init__.py +4 -0
- tdsearch-0.1.1/tdsearch/app.py +1004 -0
- tdsearch-0.1.1/tdsearch.egg-info/PKG-INFO +46 -0
- tdsearch-0.1.1/tdsearch.egg-info/SOURCES.txt +13 -0
- tdsearch-0.1.1/tdsearch.egg-info/dependency_links.txt +1 -0
- tdsearch-0.1.1/tdsearch.egg-info/entry_points.txt +2 -0
- tdsearch-0.1.1/tdsearch.egg-info/requires.txt +2 -0
- tdsearch-0.1.1/tdsearch.egg-info/top_level.txt +1 -0
tdsearch-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (s) 2026 Ivan Cvitic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction handling, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tdsearch-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tdsearch
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Desktop (GUI) for Tabular Data Search powered by xlsxgrep.
|
|
5
|
+
Home-page: https://github.com/zazuum/TDsearch
|
|
6
|
+
Author: Ivan Cvitic
|
|
7
|
+
Author-email: cviticivan@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/zazuum/TDsearch
|
|
10
|
+
Project-URL: Tracker, https://github.com/zazuum/TDsearch/issues
|
|
11
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: xlsxgrep==0.0.34
|
|
24
|
+
Requires-Dist: PySide6>=6.0.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# TDsearch (Tabular Data Search)
|
|
28
|
+
|
|
29
|
+
**TDsearch** is a desktop GUI application designed to search text across spreadsheet files including XLSX, XLS, XLSM, CSV, TSV, and ODS formats.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
- **GUI Interface:** Modern PySide6 interface with file and folder browsers.
|
|
33
|
+
- **Multiprocessing Support:** Multi-core parallel search execution.
|
|
34
|
+
- **Regex Support:** Python regex and POSIX Extended Regular Expressions (-E).
|
|
35
|
+
- **Search Options:** Ignore case (-i), fixed strings (-F), word match (-w), column search mode.
|
|
36
|
+
- **CSV Export:** Export structured search results directly to CSV.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
```sh
|
|
40
|
+
pip install tdsearch
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Launch
|
|
44
|
+
```sh
|
|
45
|
+
tdsearch
|
|
46
|
+
```
|
tdsearch-0.1.1/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# TDsearch (Tabular Data Search)
|
|
2
|
+
|
|
3
|
+
**TDsearch** is a desktop GUI application designed to search text across spreadsheet files including XLSX, XLS, XLSM, CSV, TSV, and ODS formats.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- **GUI Interface:** Modern PySide6 interface with file and folder browsers.
|
|
7
|
+
- **Multiprocessing Support:** Multi-core parallel search execution.
|
|
8
|
+
- **Regex Support:** Python regex and POSIX Extended Regular Expressions (-E).
|
|
9
|
+
- **Search Options:** Ignore case (-i), fixed strings (-F), word match (-w), column search mode.
|
|
10
|
+
- **CSV Export:** Export structured search results directly to CSV.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
```sh
|
|
14
|
+
pip install tdsearch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Launch
|
|
18
|
+
```sh
|
|
19
|
+
tdsearch
|
|
20
|
+
```
|
tdsearch-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
name = tdsearch
|
|
3
|
+
version = attr: tdsearch.__version__
|
|
4
|
+
author = Ivan Cvitic
|
|
5
|
+
author_email = cviticivan@gmail.com
|
|
6
|
+
license = MIT
|
|
7
|
+
license_files = LICENSE
|
|
8
|
+
description = Desktop (GUI) for Tabular Data Search powered by xlsxgrep.
|
|
9
|
+
url = https://github.com/zazuum/TDsearch
|
|
10
|
+
project_urls =
|
|
11
|
+
Source = https://github.com/zazuum/TDsearch
|
|
12
|
+
Tracker = https://github.com/zazuum/TDsearch/issues
|
|
13
|
+
long_description = file: README.md
|
|
14
|
+
long_description_content_type = text/markdown
|
|
15
|
+
classifiers =
|
|
16
|
+
Environment :: X11 Applications :: Qt
|
|
17
|
+
Environment :: MacOS X
|
|
18
|
+
Environment :: Win32 (MS Windows)
|
|
19
|
+
License :: OSI Approved :: MIT License
|
|
20
|
+
Intended Audience :: End Users/Desktop
|
|
21
|
+
Operating System :: OS Independent
|
|
22
|
+
Programming Language :: Python :: 3
|
|
23
|
+
Topic :: Office/Business
|
|
24
|
+
Topic :: Office/Business :: Financial :: Spreadsheet
|
|
25
|
+
Topic :: Utilities
|
|
26
|
+
|
|
27
|
+
[options]
|
|
28
|
+
packages = tdsearch
|
|
29
|
+
install_requires =
|
|
30
|
+
xlsxgrep==0.0.34
|
|
31
|
+
PySide6>=6.0.0
|
|
32
|
+
include_package_data = True
|
|
33
|
+
|
|
34
|
+
[options.entry_points]
|
|
35
|
+
gui_scripts =
|
|
36
|
+
tdsearch = tdsearch.app:main
|
|
37
|
+
|
|
38
|
+
[egg_info]
|
|
39
|
+
tag_build =
|
|
40
|
+
tag_date = 0
|
|
41
|
+
|
tdsearch-0.1.1/setup.py
ADDED
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""PySide6 Desktop Graphical User Interface (GUI) for Tabular Data Search (TDsearch)."""
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from time import perf_counter
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from PySide6.QtCore import QSettings, QStandardPaths, QThread, Signal, Qt
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QApplication,
|
|
17
|
+
QMainWindow,
|
|
18
|
+
QWidget,
|
|
19
|
+
QVBoxLayout,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QGridLayout,
|
|
22
|
+
QLabel,
|
|
23
|
+
QLineEdit,
|
|
24
|
+
QPushButton,
|
|
25
|
+
QMenu,
|
|
26
|
+
QCheckBox,
|
|
27
|
+
QComboBox,
|
|
28
|
+
QTableWidget,
|
|
29
|
+
QTableWidgetItem,
|
|
30
|
+
QFileDialog,
|
|
31
|
+
QMessageBox,
|
|
32
|
+
QStatusBar,
|
|
33
|
+
QGroupBox,
|
|
34
|
+
QHeaderView,
|
|
35
|
+
QProgressBar,
|
|
36
|
+
QDialog,
|
|
37
|
+
QDialogButtonBox,
|
|
38
|
+
QPlainTextEdit,
|
|
39
|
+
QTabWidget,
|
|
40
|
+
QSizePolicy,
|
|
41
|
+
)
|
|
42
|
+
from PySide6.QtGui import QColor, QFont, QPalette
|
|
43
|
+
PYSIDE_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
PYSIDE_AVAILABLE = False
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from xlsxgrep import process_single_file, __version__ as xlsxgrep_version
|
|
49
|
+
except ImportError:
|
|
50
|
+
try:
|
|
51
|
+
from xlsxgrep.xlsxgrep import process_single_file, __version__ as xlsxgrep_version
|
|
52
|
+
except ImportError:
|
|
53
|
+
process_single_file = None
|
|
54
|
+
xlsxgrep_version = "unknown"
|
|
55
|
+
|
|
56
|
+
from tdsearch import __version__ as tdsearch_version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SUPPORTED_EXTENSIONS = (
|
|
60
|
+
".xls",
|
|
61
|
+
".xlsx",
|
|
62
|
+
".ods",
|
|
63
|
+
".csv",
|
|
64
|
+
".tsv",
|
|
65
|
+
".xlsm",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
IGNORE_CASE_INLINE_FLAG_RE = re.compile(r"^\(\?[a-zA-Z-]*i[a-zA-Z-]*\)")
|
|
70
|
+
SETTINGS_ORGANIZATION = "TDsearch"
|
|
71
|
+
SETTINGS_APPLICATION = "TDsearch"
|
|
72
|
+
VALID_THEME_CHOICES = {"system", "dark", "light"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def collect_target_files(path_str, recursive=True):
|
|
76
|
+
path = Path(path_str)
|
|
77
|
+
file_list = []
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return file_list
|
|
80
|
+
|
|
81
|
+
if path.is_file():
|
|
82
|
+
if path.suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
83
|
+
file_list.append(str(path))
|
|
84
|
+
elif path.is_dir():
|
|
85
|
+
pattern = "**/*" if recursive else "*"
|
|
86
|
+
for p in path.glob(pattern):
|
|
87
|
+
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
88
|
+
file_list.append(str(p))
|
|
89
|
+
|
|
90
|
+
return file_list
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_search_options(
|
|
94
|
+
pattern,
|
|
95
|
+
*,
|
|
96
|
+
fixed_strings,
|
|
97
|
+
regex_enabled,
|
|
98
|
+
posix_ere,
|
|
99
|
+
ignore_case,
|
|
100
|
+
word_regexp,
|
|
101
|
+
recursive,
|
|
102
|
+
jobs,
|
|
103
|
+
count,
|
|
104
|
+
with_filename,
|
|
105
|
+
with_sheetname,
|
|
106
|
+
files_with_match,
|
|
107
|
+
files_without_match,
|
|
108
|
+
column_mode,
|
|
109
|
+
separator,
|
|
110
|
+
debug=False,
|
|
111
|
+
):
|
|
112
|
+
if regex_enabled:
|
|
113
|
+
fixed_strings = False
|
|
114
|
+
word_regexp = False
|
|
115
|
+
ignore_case = False
|
|
116
|
+
elif fixed_strings:
|
|
117
|
+
word_regexp = False
|
|
118
|
+
|
|
119
|
+
python_regex = regex_enabled and not posix_ere
|
|
120
|
+
extended_regexp = regex_enabled and posix_ere
|
|
121
|
+
effective_pattern = pattern
|
|
122
|
+
effective_ignore_case = ignore_case
|
|
123
|
+
|
|
124
|
+
if word_regexp:
|
|
125
|
+
escaped_pattern = re.escape(pattern)
|
|
126
|
+
effective_pattern = rf"(?<!\w){escaped_pattern}(?!\w)"
|
|
127
|
+
if ignore_case:
|
|
128
|
+
effective_pattern = f"(?i){effective_pattern}"
|
|
129
|
+
python_regex = True
|
|
130
|
+
extended_regexp = False
|
|
131
|
+
effective_ignore_case = False
|
|
132
|
+
word_regexp = False
|
|
133
|
+
|
|
134
|
+
elif python_regex and ignore_case and not IGNORE_CASE_INLINE_FLAG_RE.match(pattern):
|
|
135
|
+
effective_pattern = f"(?i){pattern}"
|
|
136
|
+
effective_ignore_case = False
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"PATTERN": effective_pattern,
|
|
140
|
+
"python_regex": python_regex,
|
|
141
|
+
"extended_regexp": extended_regexp,
|
|
142
|
+
"fixed_strings": fixed_strings,
|
|
143
|
+
"ignore_case": effective_ignore_case,
|
|
144
|
+
"word_regexp": word_regexp,
|
|
145
|
+
"recursive": recursive,
|
|
146
|
+
"jobs": jobs,
|
|
147
|
+
"count": count,
|
|
148
|
+
"with_filename": with_filename,
|
|
149
|
+
"with_sheetname": with_sheetname,
|
|
150
|
+
"files_with_match": files_with_match,
|
|
151
|
+
"files_without_match": files_without_match,
|
|
152
|
+
"column": column_mode,
|
|
153
|
+
"row": not column_mode,
|
|
154
|
+
"null": False,
|
|
155
|
+
"separator": separator,
|
|
156
|
+
"debug": debug,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pluralize(count, singular, plural=None):
|
|
161
|
+
if count == 1:
|
|
162
|
+
return singular
|
|
163
|
+
return plural or f"{singular}s"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def format_search_status_message(
|
|
167
|
+
opts,
|
|
168
|
+
*,
|
|
169
|
+
file_count,
|
|
170
|
+
result_count,
|
|
171
|
+
matched_group_count=0,
|
|
172
|
+
matched_file_count=0,
|
|
173
|
+
matched_cell_count=0,
|
|
174
|
+
matched_string_count=0,
|
|
175
|
+
):
|
|
176
|
+
searched_file_label = _pluralize(file_count, "file")
|
|
177
|
+
|
|
178
|
+
if opts["files_with_match"]:
|
|
179
|
+
match_label = _pluralize(matched_file_count, "matching file")
|
|
180
|
+
return (
|
|
181
|
+
f"Search completed. Found {matched_file_count} {match_label} "
|
|
182
|
+
f"across {file_count} searched {searched_file_label}."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if opts["files_without_match"]:
|
|
186
|
+
match_label = _pluralize(matched_file_count, "file without a match", "files without a match")
|
|
187
|
+
return (
|
|
188
|
+
f"Search completed. Found {matched_file_count} {match_label} "
|
|
189
|
+
f"across {file_count} searched {searched_file_label}."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if opts["count"]:
|
|
193
|
+
group_label = _pluralize(
|
|
194
|
+
matched_group_count,
|
|
195
|
+
"matching column" if opts["column"] else "matching row",
|
|
196
|
+
)
|
|
197
|
+
cell_label = _pluralize(matched_cell_count, "cell")
|
|
198
|
+
string_label = _pluralize(matched_string_count, "string")
|
|
199
|
+
return (
|
|
200
|
+
f"Search completed. Found {matched_group_count} {group_label}, "
|
|
201
|
+
f"{matched_cell_count} {cell_label}, {matched_string_count} {string_label} "
|
|
202
|
+
f"across {file_count} {searched_file_label}."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if opts["column"]:
|
|
206
|
+
result_label = _pluralize(result_count, "result cell")
|
|
207
|
+
return (
|
|
208
|
+
f"Search completed. Displaying {result_count} {result_label} "
|
|
209
|
+
f"from matching columns across {file_count} {searched_file_label}."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
result_label = _pluralize(result_count, "matching row")
|
|
213
|
+
return (
|
|
214
|
+
f"Search completed. Found {result_count} {result_label} "
|
|
215
|
+
f"across {file_count} {searched_file_label}."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def format_elapsed_time(seconds):
|
|
220
|
+
if seconds < 1:
|
|
221
|
+
return f"{seconds:.3f}s"
|
|
222
|
+
if seconds < 10:
|
|
223
|
+
return f"{seconds:.2f}s"
|
|
224
|
+
if seconds < 60:
|
|
225
|
+
return f"{seconds:.1f}s"
|
|
226
|
+
|
|
227
|
+
minutes = int(seconds // 60)
|
|
228
|
+
remaining_seconds = seconds - (minutes * 60)
|
|
229
|
+
if remaining_seconds.is_integer():
|
|
230
|
+
return f"{minutes}m {int(remaining_seconds)}s"
|
|
231
|
+
return f"{minutes}m {remaining_seconds:.1f}s"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def normalize_processing_errors(error_lines):
|
|
235
|
+
return [line.strip() for line in error_lines if line and line.strip()]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def format_processing_error_tooltip(error_count):
|
|
239
|
+
file_label = _pluralize(error_count, "file")
|
|
240
|
+
verb = "was" if error_count == 1 else "were"
|
|
241
|
+
return f"{error_count} {file_label} {verb} not processed. Click for details."
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_processing_error_summary(error_count):
|
|
245
|
+
file_label = _pluralize(error_count, "file")
|
|
246
|
+
verb = "was" if error_count == 1 else "were"
|
|
247
|
+
return f"{error_count} {file_label} {verb} not processed."
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_processing_error_details(error_lines):
|
|
251
|
+
return "\n".join(normalize_processing_errors(error_lines))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def build_progress_message(processed_files, total_files):
|
|
255
|
+
if total_files <= 0:
|
|
256
|
+
return "Searching across 0 files..."
|
|
257
|
+
return f"Searching... {processed_files}/{total_files} files processed"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def format_count_result_summary(opts, group_count, cell_count, string_count):
|
|
261
|
+
group_label = _pluralize(
|
|
262
|
+
group_count,
|
|
263
|
+
"matching column" if opts["column"] else "matching row",
|
|
264
|
+
)
|
|
265
|
+
cell_label = _pluralize(cell_count, "cell")
|
|
266
|
+
string_label = _pluralize(string_count, "string")
|
|
267
|
+
return (
|
|
268
|
+
f"{group_count} {group_label}, {cell_count} {cell_label}, "
|
|
269
|
+
f"{string_count} {string_label}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def normalize_theme_choice(theme_choice):
|
|
274
|
+
if theme_choice in VALID_THEME_CHOICES:
|
|
275
|
+
return theme_choice
|
|
276
|
+
return "system"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def build_dark_palette():
|
|
280
|
+
palette = QPalette()
|
|
281
|
+
palette.setColor(QPalette.Window, QColor(53, 53, 53))
|
|
282
|
+
palette.setColor(QPalette.WindowText, Qt.white)
|
|
283
|
+
palette.setColor(QPalette.Base, QColor(35, 35, 35))
|
|
284
|
+
palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53))
|
|
285
|
+
palette.setColor(QPalette.ToolTipBase, Qt.white)
|
|
286
|
+
palette.setColor(QPalette.ToolTipText, Qt.white)
|
|
287
|
+
palette.setColor(QPalette.Text, Qt.white)
|
|
288
|
+
palette.setColor(QPalette.Button, QColor(53, 53, 53))
|
|
289
|
+
palette.setColor(QPalette.ButtonText, Qt.white)
|
|
290
|
+
palette.setColor(QPalette.BrightText, Qt.red)
|
|
291
|
+
palette.setColor(QPalette.Link, QColor(42, 130, 218))
|
|
292
|
+
palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
|
|
293
|
+
palette.setColor(QPalette.HighlightedText, Qt.black)
|
|
294
|
+
return palette
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_light_palette():
|
|
298
|
+
palette = QPalette()
|
|
299
|
+
palette.setColor(QPalette.Window, QColor(245, 245, 245))
|
|
300
|
+
palette.setColor(QPalette.WindowText, Qt.black)
|
|
301
|
+
palette.setColor(QPalette.Base, Qt.white)
|
|
302
|
+
palette.setColor(QPalette.AlternateBase, QColor(238, 238, 238))
|
|
303
|
+
palette.setColor(QPalette.ToolTipBase, Qt.white)
|
|
304
|
+
palette.setColor(QPalette.ToolTipText, Qt.black)
|
|
305
|
+
palette.setColor(QPalette.Text, Qt.black)
|
|
306
|
+
palette.setColor(QPalette.Button, QColor(245, 245, 245))
|
|
307
|
+
palette.setColor(QPalette.ButtonText, Qt.black)
|
|
308
|
+
palette.setColor(QPalette.BrightText, Qt.red)
|
|
309
|
+
palette.setColor(QPalette.Link, QColor(0, 102, 204))
|
|
310
|
+
palette.setColor(QPalette.Highlight, QColor(76, 163, 224))
|
|
311
|
+
palette.setColor(QPalette.HighlightedText, Qt.white)
|
|
312
|
+
return palette
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if PYSIDE_AVAILABLE:
|
|
316
|
+
def default_documents_directory():
|
|
317
|
+
documents_path = QStandardPaths.writableLocation(
|
|
318
|
+
QStandardPaths.StandardLocation.DocumentsLocation
|
|
319
|
+
)
|
|
320
|
+
if documents_path:
|
|
321
|
+
return documents_path
|
|
322
|
+
return str(Path.home())
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class SearchWorkerThread(QThread):
|
|
326
|
+
"""Background thread for executing searches without freezing the GUI."""
|
|
327
|
+
|
|
328
|
+
result_signal = Signal(dict)
|
|
329
|
+
progress_signal = Signal(int, int, str)
|
|
330
|
+
|
|
331
|
+
def __init__(self, path_str, recursive, opts):
|
|
332
|
+
super().__init__()
|
|
333
|
+
self.path_str = path_str
|
|
334
|
+
self.recursive = recursive
|
|
335
|
+
self.opts = opts
|
|
336
|
+
|
|
337
|
+
def run(self):
|
|
338
|
+
if process_single_file is None:
|
|
339
|
+
self.result_signal.emit({
|
|
340
|
+
"results": [],
|
|
341
|
+
"file_count": 0,
|
|
342
|
+
"result_count": 0,
|
|
343
|
+
"matched_group_count": 0,
|
|
344
|
+
"matched_cell_count": 0,
|
|
345
|
+
"matched_string_count": 0,
|
|
346
|
+
"matched_file_count": 0,
|
|
347
|
+
"error": "xlsxgrep engine is not installed. Please run: pip install xlsxgrep"
|
|
348
|
+
})
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
files = collect_target_files(self.path_str, self.recursive)
|
|
352
|
+
results = []
|
|
353
|
+
matched_group_count = 0
|
|
354
|
+
matched_cell_count = 0
|
|
355
|
+
matched_string_count = 0
|
|
356
|
+
matched_file_count = 0
|
|
357
|
+
processing_errors = []
|
|
358
|
+
|
|
359
|
+
total_files = len(files)
|
|
360
|
+
self.progress_signal.emit(0, max(total_files, 1), build_progress_message(0, total_files))
|
|
361
|
+
|
|
362
|
+
for idx, f in enumerate(files):
|
|
363
|
+
res = process_single_file(f, self.opts)
|
|
364
|
+
stdout_lines = res.get("stdout", [])
|
|
365
|
+
counts = res.get("counts", (0, 0, 0))
|
|
366
|
+
processing_errors.extend(normalize_processing_errors(res.get("stderr", [])))
|
|
367
|
+
matched_group_count += counts[0]
|
|
368
|
+
matched_cell_count += counts[1]
|
|
369
|
+
matched_string_count += counts[2]
|
|
370
|
+
if stdout_lines:
|
|
371
|
+
matched_file_count += 1
|
|
372
|
+
|
|
373
|
+
if self.opts["count"]:
|
|
374
|
+
if counts[0] > 0:
|
|
375
|
+
results.append((
|
|
376
|
+
f,
|
|
377
|
+
"",
|
|
378
|
+
format_count_result_summary(self.opts, counts[0], counts[1], counts[2]),
|
|
379
|
+
))
|
|
380
|
+
else:
|
|
381
|
+
for line in stdout_lines:
|
|
382
|
+
line_str = line.rstrip("\r\n")
|
|
383
|
+
file_name = ""
|
|
384
|
+
sheet_name = ""
|
|
385
|
+
content = line_str
|
|
386
|
+
if self.opts["with_filename"] and self.opts["with_sheetname"]:
|
|
387
|
+
parts = line_str.split(": ", 2)
|
|
388
|
+
else:
|
|
389
|
+
parts = line_str.split(": ", 1)
|
|
390
|
+
|
|
391
|
+
if self.opts["with_filename"] and self.opts["with_sheetname"] and len(parts) == 3:
|
|
392
|
+
file_name, sheet_name, content = parts
|
|
393
|
+
elif self.opts["with_filename"] and len(parts) == 2:
|
|
394
|
+
file_name, content = parts
|
|
395
|
+
elif self.opts["with_sheetname"] and len(parts) == 2:
|
|
396
|
+
sheet_name, content = parts
|
|
397
|
+
|
|
398
|
+
results.append((file_name, sheet_name, content))
|
|
399
|
+
|
|
400
|
+
processed_files = idx + 1
|
|
401
|
+
self.progress_signal.emit(
|
|
402
|
+
processed_files,
|
|
403
|
+
max(total_files, 1),
|
|
404
|
+
build_progress_message(processed_files, total_files),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
self.result_signal.emit({
|
|
408
|
+
"results": results,
|
|
409
|
+
"file_count": len(files),
|
|
410
|
+
"result_count": len(results),
|
|
411
|
+
"matched_group_count": matched_group_count,
|
|
412
|
+
"matched_cell_count": matched_cell_count,
|
|
413
|
+
"matched_string_count": matched_string_count,
|
|
414
|
+
"matched_file_count": matched_file_count,
|
|
415
|
+
"processing_errors": processing_errors,
|
|
416
|
+
"status_message": format_search_status_message(
|
|
417
|
+
self.opts,
|
|
418
|
+
file_count=len(files),
|
|
419
|
+
result_count=len(results),
|
|
420
|
+
matched_group_count=matched_group_count,
|
|
421
|
+
matched_file_count=matched_file_count,
|
|
422
|
+
matched_cell_count=matched_cell_count,
|
|
423
|
+
matched_string_count=matched_string_count,
|
|
424
|
+
),
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
class TDSearchGUI(QMainWindow):
|
|
428
|
+
"""TDsearch PySide6 Desktop Application."""
|
|
429
|
+
|
|
430
|
+
def __init__(self):
|
|
431
|
+
super().__init__()
|
|
432
|
+
self.setWindowTitle(f"TDsearch v{tdsearch_version} (xlsxgrep v{xlsxgrep_version})")
|
|
433
|
+
self.resize(900, 720)
|
|
434
|
+
self.search_start_time = None
|
|
435
|
+
self.processing_errors = []
|
|
436
|
+
self.settings = QSettings(SETTINGS_ORGANIZATION, SETTINGS_APPLICATION)
|
|
437
|
+
app = QApplication.instance()
|
|
438
|
+
self.original_palette = QPalette(app.palette())
|
|
439
|
+
self.original_style_name = app.style().objectName()
|
|
440
|
+
self.original_stylesheet = app.styleSheet()
|
|
441
|
+
|
|
442
|
+
self.init_ui()
|
|
443
|
+
|
|
444
|
+
def init_ui(self):
|
|
445
|
+
central_widget = QWidget()
|
|
446
|
+
self.setCentralWidget(central_widget)
|
|
447
|
+
main_layout = QVBoxLayout(central_widget)
|
|
448
|
+
|
|
449
|
+
# --- Search Controls Panel ---
|
|
450
|
+
panel = QGroupBox("Tabular Data Search Parameters")
|
|
451
|
+
panel_layout = QVBoxLayout(panel)
|
|
452
|
+
|
|
453
|
+
# Pattern row
|
|
454
|
+
row1 = QHBoxLayout()
|
|
455
|
+
row1.addWidget(QLabel("Pattern:"))
|
|
456
|
+
self.inp_pattern = QLineEdit()
|
|
457
|
+
self.inp_pattern.setPlaceholderText("Enter search string or regex pattern...")
|
|
458
|
+
self.inp_pattern.returnPressed.connect(self.start_search)
|
|
459
|
+
row1.addWidget(self.inp_pattern)
|
|
460
|
+
panel_layout.addLayout(row1)
|
|
461
|
+
|
|
462
|
+
# Path row
|
|
463
|
+
row2 = QHBoxLayout()
|
|
464
|
+
row2.addWidget(QLabel("Path:"))
|
|
465
|
+
self.inp_path = QLineEdit(default_documents_directory())
|
|
466
|
+
self.inp_path.setPlaceholderText("File or directory path...")
|
|
467
|
+
row2.addWidget(self.inp_path)
|
|
468
|
+
|
|
469
|
+
btn_open = QPushButton("Open...")
|
|
470
|
+
open_menu = QMenu(btn_open)
|
|
471
|
+
open_file_action = open_menu.addAction("Open File...")
|
|
472
|
+
open_file_action.triggered.connect(self.browse_file)
|
|
473
|
+
open_folder_action = open_menu.addAction("Open Folder...")
|
|
474
|
+
open_folder_action.triggered.connect(self.browse_directory)
|
|
475
|
+
btn_open.setMenu(open_menu)
|
|
476
|
+
row2.addWidget(btn_open)
|
|
477
|
+
|
|
478
|
+
panel_layout.addLayout(row2)
|
|
479
|
+
|
|
480
|
+
self.options_tabs = QTabWidget()
|
|
481
|
+
panel_layout.addWidget(self.options_tabs)
|
|
482
|
+
|
|
483
|
+
search_options_tab = QWidget()
|
|
484
|
+
search_options_layout = QVBoxLayout(search_options_tab)
|
|
485
|
+
search_options_layout.setContentsMargins(8, 8, 8, 8)
|
|
486
|
+
|
|
487
|
+
options_grid = QGridLayout()
|
|
488
|
+
options_grid.setHorizontalSpacing(18)
|
|
489
|
+
options_grid.setVerticalSpacing(8)
|
|
490
|
+
for column in range(4):
|
|
491
|
+
options_grid.setColumnStretch(column, 1)
|
|
492
|
+
|
|
493
|
+
self.chk_word_regexp = QCheckBox("Match whole words only")
|
|
494
|
+
self.chk_ignore_case = QCheckBox("Ignore uppercase/lowercase")
|
|
495
|
+
self.chk_ignore_case.setChecked(True)
|
|
496
|
+
self.chk_fixed_strings = QCheckBox("Plain text search")
|
|
497
|
+
self.chk_fixed_strings.setChecked(True)
|
|
498
|
+
self.combo_regex_type = QComboBox()
|
|
499
|
+
self.combo_regex_type.addItem("None", None)
|
|
500
|
+
self.combo_regex_type.addItem("Python regex", False)
|
|
501
|
+
self.combo_regex_type.addItem("POSIX regex", True)
|
|
502
|
+
self.chk_count = QCheckBox("Show counts only")
|
|
503
|
+
|
|
504
|
+
regex_options = QWidget()
|
|
505
|
+
regex_options_layout = QHBoxLayout(regex_options)
|
|
506
|
+
regex_options_layout.setContentsMargins(0, 0, 0, 0)
|
|
507
|
+
regex_options_layout.setSpacing(6)
|
|
508
|
+
regex_options_layout.addWidget(QLabel("Regex type:"))
|
|
509
|
+
self.combo_regex_type.setFixedWidth(120)
|
|
510
|
+
regex_options_layout.addWidget(self.combo_regex_type)
|
|
511
|
+
regex_options.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
|
512
|
+
|
|
513
|
+
options_grid.addWidget(self.chk_fixed_strings, 0, 0)
|
|
514
|
+
options_grid.addWidget(self.chk_word_regexp, 0, 1)
|
|
515
|
+
options_grid.addWidget(self.chk_ignore_case, 0, 2)
|
|
516
|
+
|
|
517
|
+
self.chk_recursive = QCheckBox("Search subfolders")
|
|
518
|
+
self.chk_recursive.setChecked(True)
|
|
519
|
+
self.chk_filename = QCheckBox("Show file name")
|
|
520
|
+
self.chk_sheetname = QCheckBox("Show sheet name")
|
|
521
|
+
self.chk_lmatch = QCheckBox("Show only matching files")
|
|
522
|
+
self.chk_lwithout = QCheckBox("Show only non-matching files")
|
|
523
|
+
|
|
524
|
+
options_grid.addWidget(self.chk_filename, 1, 0)
|
|
525
|
+
options_grid.addWidget(self.chk_sheetname, 1, 1)
|
|
526
|
+
options_grid.addWidget(self.chk_count, 1, 2)
|
|
527
|
+
options_grid.addWidget(self.chk_lmatch, 2, 0)
|
|
528
|
+
options_grid.addWidget(self.chk_lwithout, 2, 1)
|
|
529
|
+
|
|
530
|
+
self.chk_lmatch.toggled.connect(
|
|
531
|
+
lambda checked: self.chk_lwithout.setChecked(False) if checked else None
|
|
532
|
+
)
|
|
533
|
+
self.chk_lwithout.toggled.connect(
|
|
534
|
+
lambda checked: self.chk_lmatch.setChecked(False) if checked else None
|
|
535
|
+
)
|
|
536
|
+
self.chk_fixed_strings.toggled.connect(self.on_fixed_strings_toggled)
|
|
537
|
+
self.chk_word_regexp.toggled.connect(self.on_word_regexp_toggled)
|
|
538
|
+
self.combo_regex_type.currentIndexChanged.connect(self.on_regex_type_changed)
|
|
539
|
+
self.chk_ignore_case.toggled.connect(self.on_ignore_case_toggled)
|
|
540
|
+
self.chk_count.toggled.connect(self.on_count_toggled)
|
|
541
|
+
self.combo_search_mode = QComboBox()
|
|
542
|
+
self.combo_search_mode.addItem("Columns", True)
|
|
543
|
+
self.combo_search_mode.addItem("Rows", False)
|
|
544
|
+
self.combo_search_mode.setCurrentIndex(1)
|
|
545
|
+
self.chk_custom_separator = QCheckBox("Custom separator")
|
|
546
|
+
self.inp_sep = QLineEdit("\\t")
|
|
547
|
+
self.inp_sep.setFixedWidth(60)
|
|
548
|
+
self.inp_sep.setEnabled(False)
|
|
549
|
+
self.chk_custom_separator.toggled.connect(self.inp_sep.setEnabled)
|
|
550
|
+
|
|
551
|
+
search_mode_options = QWidget()
|
|
552
|
+
search_mode_layout = QHBoxLayout(search_mode_options)
|
|
553
|
+
search_mode_layout.setContentsMargins(0, 0, 0, 0)
|
|
554
|
+
search_mode_layout.addWidget(QLabel("Search by:"))
|
|
555
|
+
search_mode_layout.addWidget(self.combo_search_mode)
|
|
556
|
+
|
|
557
|
+
separator_options = QWidget()
|
|
558
|
+
separator_layout = QHBoxLayout(separator_options)
|
|
559
|
+
separator_layout.setContentsMargins(0, 0, 0, 0)
|
|
560
|
+
separator_layout.setSpacing(6)
|
|
561
|
+
separator_layout.addWidget(self.chk_custom_separator)
|
|
562
|
+
separator_layout.addWidget(self.inp_sep, 0, Qt.AlignLeft)
|
|
563
|
+
separator_options.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
|
|
564
|
+
|
|
565
|
+
right_options = QWidget()
|
|
566
|
+
right_options_layout = QVBoxLayout(right_options)
|
|
567
|
+
right_options_layout.setContentsMargins(0, 0, 0, 0)
|
|
568
|
+
right_options_layout.setSpacing(8)
|
|
569
|
+
right_options_layout.addWidget(regex_options)
|
|
570
|
+
right_options_layout.addWidget(search_mode_options)
|
|
571
|
+
right_options_layout.addWidget(separator_options)
|
|
572
|
+
right_options_layout.addStretch()
|
|
573
|
+
right_options.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
|
574
|
+
|
|
575
|
+
options_grid.addWidget(self.chk_recursive, 2, 2)
|
|
576
|
+
options_grid.addWidget(right_options, 0, 3, 3, 1, Qt.AlignTop | Qt.AlignLeft)
|
|
577
|
+
search_options_layout.addLayout(options_grid)
|
|
578
|
+
|
|
579
|
+
self.options_tabs.addTab(search_options_tab, "Search options")
|
|
580
|
+
|
|
581
|
+
other_options_tab = QWidget()
|
|
582
|
+
other_options_layout = QGridLayout(other_options_tab)
|
|
583
|
+
other_options_layout.setContentsMargins(8, 8, 8, 8)
|
|
584
|
+
other_options_layout.setHorizontalSpacing(20)
|
|
585
|
+
other_options_layout.setVerticalSpacing(12)
|
|
586
|
+
self.combo_theme = QComboBox()
|
|
587
|
+
self.combo_theme.addItem("System default", "system")
|
|
588
|
+
self.combo_theme.addItem("Dark", "dark")
|
|
589
|
+
self.combo_theme.addItem("Light", "light")
|
|
590
|
+
self.combo_search_engine = QComboBox()
|
|
591
|
+
self.combo_search_engine.addItem(f"xlsxgrep v{xlsxgrep_version}", "xlsxgrep")
|
|
592
|
+
self.combo_jobs = QComboBox()
|
|
593
|
+
cpu_count = os.cpu_count() or 1
|
|
594
|
+
self.combo_jobs.addItem("Auto (All Cores)", 0)
|
|
595
|
+
self.combo_jobs.addItem("1 Core (Sequential)", 1)
|
|
596
|
+
for c in range(2, min(cpu_count + 1, 17)):
|
|
597
|
+
self.combo_jobs.addItem(f"{c} CPU Cores", c)
|
|
598
|
+
|
|
599
|
+
other_options_layout.addWidget(QLabel("Appearance:"), 0, 0)
|
|
600
|
+
other_options_layout.addWidget(self.combo_theme, 0, 1)
|
|
601
|
+
other_options_layout.addWidget(QLabel("Search engine:"), 1, 0)
|
|
602
|
+
other_options_layout.addWidget(self.combo_search_engine, 1, 1)
|
|
603
|
+
other_options_layout.addWidget(QLabel("CPU Usage:"), 2, 0)
|
|
604
|
+
other_options_layout.addWidget(self.combo_jobs, 2, 1)
|
|
605
|
+
other_options_layout.setColumnStretch(1, 1)
|
|
606
|
+
|
|
607
|
+
self.options_tabs.addTab(other_options_tab, "Other Options")
|
|
608
|
+
self.options_tabs.setCurrentIndex(0)
|
|
609
|
+
|
|
610
|
+
# Buttons Row
|
|
611
|
+
row6 = QHBoxLayout()
|
|
612
|
+
row6.addStretch()
|
|
613
|
+
|
|
614
|
+
self.btn_search = QPushButton("Search")
|
|
615
|
+
self.btn_search.setDefault(True)
|
|
616
|
+
self.btn_search.clicked.connect(self.start_search)
|
|
617
|
+
row6.addWidget(self.btn_search)
|
|
618
|
+
|
|
619
|
+
btn_export = QPushButton("Export CSV...")
|
|
620
|
+
btn_export.clicked.connect(self.export_csv)
|
|
621
|
+
row6.addWidget(btn_export)
|
|
622
|
+
|
|
623
|
+
btn_reset_options = QPushButton("Reset options")
|
|
624
|
+
btn_reset_options.clicked.connect(self.reset_options)
|
|
625
|
+
row6.addWidget(btn_reset_options)
|
|
626
|
+
|
|
627
|
+
btn_clear = QPushButton("Clear Results")
|
|
628
|
+
btn_clear.clicked.connect(self.clear_results)
|
|
629
|
+
row6.addWidget(btn_clear)
|
|
630
|
+
|
|
631
|
+
panel_layout.addLayout(row6)
|
|
632
|
+
main_layout.addWidget(panel)
|
|
633
|
+
|
|
634
|
+
# --- Results Table ---
|
|
635
|
+
self.table = QTableWidget(0, 3)
|
|
636
|
+
self.table.setHorizontalHeaderLabels(["File", "Sheet / Position", "Matched Row / Line"])
|
|
637
|
+
self.table.setWordWrap(False)
|
|
638
|
+
self.table.setAlternatingRowColors(True)
|
|
639
|
+
self.reset_results_table_layout()
|
|
640
|
+
self.chk_filename.toggled.connect(self.update_output_columns)
|
|
641
|
+
self.chk_sheetname.toggled.connect(self.update_output_columns)
|
|
642
|
+
self.update_output_columns()
|
|
643
|
+
self.restore_selected_theme()
|
|
644
|
+
self.combo_theme.currentIndexChanged.connect(self.on_theme_changed)
|
|
645
|
+
main_layout.addWidget(self.table)
|
|
646
|
+
|
|
647
|
+
# --- Status Bar & Progress Bar ---
|
|
648
|
+
self.status_bar = QStatusBar()
|
|
649
|
+
self.setStatusBar(self.status_bar)
|
|
650
|
+
self.status_bar.showMessage("Ready.")
|
|
651
|
+
|
|
652
|
+
self.btn_processing_warnings = QPushButton("⚠")
|
|
653
|
+
self.btn_processing_warnings.setFlat(True)
|
|
654
|
+
self.btn_processing_warnings.setVisible(False)
|
|
655
|
+
self.btn_processing_warnings.setToolTip("")
|
|
656
|
+
self.btn_processing_warnings.clicked.connect(self.show_processing_errors_dialog)
|
|
657
|
+
self.status_bar.addPermanentWidget(self.btn_processing_warnings)
|
|
658
|
+
|
|
659
|
+
self.lbl_elapsed_time = QLabel("")
|
|
660
|
+
self.status_bar.addPermanentWidget(self.lbl_elapsed_time)
|
|
661
|
+
|
|
662
|
+
self.progress_bar = QProgressBar()
|
|
663
|
+
self.progress_bar.setRange(0, 100)
|
|
664
|
+
self.progress_bar.setValue(0)
|
|
665
|
+
self.progress_bar.setFormat("%p%")
|
|
666
|
+
self.progress_bar.setVisible(False)
|
|
667
|
+
self.status_bar.addPermanentWidget(self.progress_bar)
|
|
668
|
+
|
|
669
|
+
def browse_file(self):
|
|
670
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
671
|
+
self,
|
|
672
|
+
"Select Spreadsheet File",
|
|
673
|
+
self.inp_path.text().strip() or default_documents_directory(),
|
|
674
|
+
"Spreadsheet Files (*.xlsx *.xls *.xlsm *.csv *.tsv *.ods);;All Files (*)",
|
|
675
|
+
)
|
|
676
|
+
if file_path:
|
|
677
|
+
self.inp_path.setText(file_path)
|
|
678
|
+
|
|
679
|
+
def browse_directory(self):
|
|
680
|
+
dir_path = QFileDialog.getExistingDirectory(
|
|
681
|
+
self,
|
|
682
|
+
"Select Folder to Search",
|
|
683
|
+
self.inp_path.text().strip() or default_documents_directory(),
|
|
684
|
+
)
|
|
685
|
+
if dir_path:
|
|
686
|
+
self.inp_path.setText(dir_path)
|
|
687
|
+
|
|
688
|
+
def clear_results(self):
|
|
689
|
+
self.table.setRowCount(0)
|
|
690
|
+
self.reset_results_table_layout()
|
|
691
|
+
self.table.verticalScrollBar().setValue(0)
|
|
692
|
+
self.search_start_time = None
|
|
693
|
+
self.lbl_elapsed_time.setText("")
|
|
694
|
+
self.set_processing_errors([])
|
|
695
|
+
self.status_bar.showMessage("Results cleared.", 3000)
|
|
696
|
+
|
|
697
|
+
def reset_options(self):
|
|
698
|
+
self.chk_ignore_case.setChecked(True)
|
|
699
|
+
self.chk_word_regexp.setChecked(False)
|
|
700
|
+
self.chk_fixed_strings.setChecked(True)
|
|
701
|
+
self.combo_regex_type.setCurrentIndex(0)
|
|
702
|
+
self.chk_count.setChecked(False)
|
|
703
|
+
|
|
704
|
+
self.chk_recursive.setChecked(True)
|
|
705
|
+
self.chk_filename.setChecked(False)
|
|
706
|
+
self.chk_sheetname.setChecked(False)
|
|
707
|
+
self.chk_lmatch.setChecked(False)
|
|
708
|
+
self.chk_lwithout.setChecked(False)
|
|
709
|
+
|
|
710
|
+
self.combo_search_mode.setCurrentIndex(1)
|
|
711
|
+
self.chk_custom_separator.setChecked(False)
|
|
712
|
+
self.inp_sep.setText("\\t")
|
|
713
|
+
|
|
714
|
+
theme_index = self.combo_theme.findData("system")
|
|
715
|
+
if theme_index < 0:
|
|
716
|
+
theme_index = 0
|
|
717
|
+
self.combo_theme.setCurrentIndex(theme_index)
|
|
718
|
+
self.combo_search_engine.setCurrentIndex(0)
|
|
719
|
+
|
|
720
|
+
jobs_index = self.combo_jobs.findData(0)
|
|
721
|
+
if jobs_index < 0:
|
|
722
|
+
jobs_index = 0
|
|
723
|
+
self.combo_jobs.setCurrentIndex(jobs_index)
|
|
724
|
+
|
|
725
|
+
self.options_tabs.setCurrentIndex(0)
|
|
726
|
+
self.status_bar.showMessage("Options reset.", 3000)
|
|
727
|
+
|
|
728
|
+
def reset_results_table_layout(self):
|
|
729
|
+
header = self.table.horizontalHeader()
|
|
730
|
+
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
731
|
+
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
732
|
+
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
733
|
+
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
|
734
|
+
header.setStretchLastSection(True)
|
|
735
|
+
self.table.horizontalScrollBar().setValue(0)
|
|
736
|
+
|
|
737
|
+
def fit_results_table_to_contents(self):
|
|
738
|
+
header = self.table.horizontalHeader()
|
|
739
|
+
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
740
|
+
header.setSectionResizeMode(0, QHeaderView.Interactive)
|
|
741
|
+
header.setSectionResizeMode(1, QHeaderView.Interactive)
|
|
742
|
+
header.setSectionResizeMode(2, QHeaderView.Interactive)
|
|
743
|
+
header.setStretchLastSection(False)
|
|
744
|
+
self.table.resizeColumnsToContents()
|
|
745
|
+
self.ensure_file_column_width()
|
|
746
|
+
self.table.horizontalScrollBar().setValue(0)
|
|
747
|
+
|
|
748
|
+
def update_output_columns(self):
|
|
749
|
+
self.table.setColumnHidden(0, not self.chk_filename.isChecked())
|
|
750
|
+
self.table.setColumnHidden(1, not self.chk_sheetname.isChecked())
|
|
751
|
+
self.ensure_file_column_width()
|
|
752
|
+
|
|
753
|
+
def ensure_file_column_width(self):
|
|
754
|
+
if self.table.isColumnHidden(0):
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
header = self.table.horizontalHeader()
|
|
758
|
+
sheet_width = max(self.table.columnWidth(1), header.defaultSectionSize())
|
|
759
|
+
minimum_file_width = sheet_width * 2
|
|
760
|
+
if self.table.columnWidth(0) < minimum_file_width:
|
|
761
|
+
self.table.setColumnWidth(0, minimum_file_width)
|
|
762
|
+
|
|
763
|
+
def on_fixed_strings_toggled(self, checked):
|
|
764
|
+
if checked:
|
|
765
|
+
self.chk_word_regexp.setChecked(False)
|
|
766
|
+
self.combo_regex_type.setCurrentIndex(0)
|
|
767
|
+
|
|
768
|
+
def on_word_regexp_toggled(self, checked):
|
|
769
|
+
if checked:
|
|
770
|
+
self.chk_fixed_strings.setChecked(False)
|
|
771
|
+
self.combo_regex_type.setCurrentIndex(0)
|
|
772
|
+
|
|
773
|
+
def on_regex_type_changed(self, index):
|
|
774
|
+
if self.combo_regex_type.itemData(index) is not None:
|
|
775
|
+
self.chk_fixed_strings.setChecked(False)
|
|
776
|
+
self.chk_word_regexp.setChecked(False)
|
|
777
|
+
self.chk_ignore_case.setChecked(False)
|
|
778
|
+
|
|
779
|
+
def on_ignore_case_toggled(self, checked):
|
|
780
|
+
if checked:
|
|
781
|
+
self.combo_regex_type.setCurrentIndex(0)
|
|
782
|
+
|
|
783
|
+
def on_count_toggled(self, checked):
|
|
784
|
+
self.update_output_columns()
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def restore_selected_theme(self):
|
|
788
|
+
saved_theme = normalize_theme_choice(self.settings.value("ui/theme", "system"))
|
|
789
|
+
theme_index = self.combo_theme.findData(saved_theme)
|
|
790
|
+
if theme_index < 0:
|
|
791
|
+
theme_index = 0
|
|
792
|
+
self.combo_theme.setCurrentIndex(theme_index)
|
|
793
|
+
self.apply_selected_theme(saved_theme)
|
|
794
|
+
|
|
795
|
+
def on_theme_changed(self):
|
|
796
|
+
selected_theme = normalize_theme_choice(self.combo_theme.currentData())
|
|
797
|
+
self.apply_selected_theme(selected_theme)
|
|
798
|
+
self.settings.setValue("ui/theme", selected_theme)
|
|
799
|
+
|
|
800
|
+
def apply_selected_theme(self, selected_theme=None):
|
|
801
|
+
app = QApplication.instance()
|
|
802
|
+
selected_theme = normalize_theme_choice(selected_theme or self.combo_theme.currentData())
|
|
803
|
+
|
|
804
|
+
if selected_theme == "dark":
|
|
805
|
+
app.setStyle("Fusion")
|
|
806
|
+
app.setPalette(build_dark_palette())
|
|
807
|
+
app.setStyleSheet("")
|
|
808
|
+
elif selected_theme == "light":
|
|
809
|
+
app.setStyle("Fusion")
|
|
810
|
+
app.setPalette(build_light_palette())
|
|
811
|
+
app.setStyleSheet("")
|
|
812
|
+
else:
|
|
813
|
+
app.setStyle(self.original_style_name)
|
|
814
|
+
app.setPalette(QPalette(self.original_palette))
|
|
815
|
+
app.setStyleSheet(self.original_stylesheet)
|
|
816
|
+
|
|
817
|
+
def set_processing_errors(self, processing_errors):
|
|
818
|
+
self.processing_errors = normalize_processing_errors(processing_errors)
|
|
819
|
+
if self.processing_errors:
|
|
820
|
+
self.btn_processing_warnings.setToolTip(
|
|
821
|
+
format_processing_error_tooltip(len(self.processing_errors))
|
|
822
|
+
)
|
|
823
|
+
self.btn_processing_warnings.setVisible(True)
|
|
824
|
+
else:
|
|
825
|
+
self.btn_processing_warnings.setVisible(False)
|
|
826
|
+
self.btn_processing_warnings.setToolTip("")
|
|
827
|
+
|
|
828
|
+
def show_processing_errors_dialog(self):
|
|
829
|
+
if not self.processing_errors:
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
dialog = QDialog(self)
|
|
833
|
+
dialog.setWindowTitle("Some Files Were Not Processed")
|
|
834
|
+
dialog.resize(int(self.width() * 0.6), int(self.height() * 0.5))
|
|
835
|
+
|
|
836
|
+
layout = QVBoxLayout(dialog)
|
|
837
|
+
|
|
838
|
+
summary_label = QLabel(format_processing_error_summary(len(self.processing_errors)))
|
|
839
|
+
summary_label.setWordWrap(True)
|
|
840
|
+
layout.addWidget(summary_label)
|
|
841
|
+
|
|
842
|
+
info_label = QLabel(
|
|
843
|
+
"Some files may be corrupted, password protected, unsupported, or otherwise unreadable. "
|
|
844
|
+
"Review the log below for the skipped files and reported reasons."
|
|
845
|
+
)
|
|
846
|
+
info_label.setWordWrap(True)
|
|
847
|
+
layout.addWidget(info_label)
|
|
848
|
+
|
|
849
|
+
details_edit = QPlainTextEdit()
|
|
850
|
+
details_edit.setReadOnly(True)
|
|
851
|
+
details_edit.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
852
|
+
details_edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
853
|
+
details_edit.setPlainText(build_processing_error_details(self.processing_errors))
|
|
854
|
+
layout.addWidget(details_edit)
|
|
855
|
+
|
|
856
|
+
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
|
|
857
|
+
button_box.accepted.connect(dialog.accept)
|
|
858
|
+
layout.addWidget(button_box)
|
|
859
|
+
|
|
860
|
+
dialog.exec()
|
|
861
|
+
|
|
862
|
+
def start_search(self):
|
|
863
|
+
pattern = self.inp_pattern.text().strip()
|
|
864
|
+
if not pattern:
|
|
865
|
+
QMessageBox.warning(self, "Missing Pattern", "Please enter a search pattern.")
|
|
866
|
+
return
|
|
867
|
+
|
|
868
|
+
path_str = self.inp_path.text().strip() or "."
|
|
869
|
+
fixed_strings = self.chk_fixed_strings.isChecked()
|
|
870
|
+
regex_type = self.combo_regex_type.currentData()
|
|
871
|
+
posix_ere = regex_type is True
|
|
872
|
+
regex_enabled = regex_type is not None
|
|
873
|
+
column_mode = self.combo_search_mode.currentData()
|
|
874
|
+
separator = "\t"
|
|
875
|
+
if self.chk_custom_separator.isChecked():
|
|
876
|
+
separator = self.inp_sep.text()
|
|
877
|
+
if separator == "\\t":
|
|
878
|
+
separator = "\t"
|
|
879
|
+
|
|
880
|
+
opts = build_search_options(
|
|
881
|
+
pattern,
|
|
882
|
+
fixed_strings=fixed_strings,
|
|
883
|
+
regex_enabled=regex_enabled,
|
|
884
|
+
posix_ere=posix_ere,
|
|
885
|
+
ignore_case=self.chk_ignore_case.isChecked(),
|
|
886
|
+
word_regexp=self.chk_word_regexp.isChecked(),
|
|
887
|
+
recursive=self.chk_recursive.isChecked(),
|
|
888
|
+
jobs=self.combo_jobs.currentData(),
|
|
889
|
+
count=self.chk_count.isChecked(),
|
|
890
|
+
with_filename=True,
|
|
891
|
+
with_sheetname=True,
|
|
892
|
+
files_with_match=self.chk_lmatch.isChecked(),
|
|
893
|
+
files_without_match=self.chk_lwithout.isChecked(),
|
|
894
|
+
column_mode=column_mode,
|
|
895
|
+
separator=separator,
|
|
896
|
+
debug=False,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
self.btn_search.setEnabled(False)
|
|
900
|
+
self.progress_bar.setRange(0, 1)
|
|
901
|
+
self.progress_bar.setValue(0)
|
|
902
|
+
self.progress_bar.setVisible(True)
|
|
903
|
+
self.table.setRowCount(0)
|
|
904
|
+
self.reset_results_table_layout()
|
|
905
|
+
self.table.verticalScrollBar().setValue(0)
|
|
906
|
+
self.search_start_time = perf_counter()
|
|
907
|
+
self.lbl_elapsed_time.setText("")
|
|
908
|
+
self.set_processing_errors([])
|
|
909
|
+
self.status_bar.showMessage(f"Searching for '{pattern}' in {path_str}...")
|
|
910
|
+
|
|
911
|
+
# Run search in background thread
|
|
912
|
+
self.worker = SearchWorkerThread(path_str, self.chk_recursive.isChecked(), opts)
|
|
913
|
+
self.worker.result_signal.connect(self.on_search_finished)
|
|
914
|
+
self.worker.progress_signal.connect(self.on_search_progress)
|
|
915
|
+
self.worker.start()
|
|
916
|
+
|
|
917
|
+
def on_search_progress(self, value, maximum, msg):
|
|
918
|
+
self.progress_bar.setRange(0, maximum)
|
|
919
|
+
self.progress_bar.setValue(value)
|
|
920
|
+
self.status_bar.showMessage(msg)
|
|
921
|
+
|
|
922
|
+
def on_search_finished(self, data):
|
|
923
|
+
self.btn_search.setEnabled(True)
|
|
924
|
+
self.progress_bar.setValue(self.progress_bar.maximum())
|
|
925
|
+
self.progress_bar.setVisible(False)
|
|
926
|
+
|
|
927
|
+
elapsed_time = None
|
|
928
|
+
if self.search_start_time is not None:
|
|
929
|
+
elapsed_time = perf_counter() - self.search_start_time
|
|
930
|
+
self.search_start_time = None
|
|
931
|
+
self.lbl_elapsed_time.setText(f"Time: {format_elapsed_time(elapsed_time)}")
|
|
932
|
+
|
|
933
|
+
if "error" in data:
|
|
934
|
+
self.set_processing_errors([])
|
|
935
|
+
QMessageBox.critical(self, "Engine Error", data["error"])
|
|
936
|
+
self.status_bar.showMessage("Error: " + data["error"])
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
self.set_processing_errors(data.get("processing_errors", []))
|
|
940
|
+
results = data["results"]
|
|
941
|
+
self.table.setRowCount(len(results))
|
|
942
|
+
for row_idx, (file_name, sheet_name, content) in enumerate(results):
|
|
943
|
+
self.table.setItem(row_idx, 0, QTableWidgetItem(file_name))
|
|
944
|
+
self.table.setItem(row_idx, 1, QTableWidgetItem(sheet_name))
|
|
945
|
+
self.table.setItem(row_idx, 2, QTableWidgetItem(content))
|
|
946
|
+
|
|
947
|
+
if results:
|
|
948
|
+
self.fit_results_table_to_contents()
|
|
949
|
+
else:
|
|
950
|
+
self.reset_results_table_layout()
|
|
951
|
+
self.table.verticalScrollBar().setValue(0)
|
|
952
|
+
self.status_bar.showMessage(data["status_message"])
|
|
953
|
+
|
|
954
|
+
def export_csv(self):
|
|
955
|
+
row_count = self.table.rowCount()
|
|
956
|
+
if row_count == 0:
|
|
957
|
+
QMessageBox.warning(self, "No Results", "There are no search results to export.")
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
961
|
+
self, "Export Results to CSV", "tdsearch_export.csv", "CSV Files (*.csv)"
|
|
962
|
+
)
|
|
963
|
+
if not file_path:
|
|
964
|
+
return
|
|
965
|
+
|
|
966
|
+
try:
|
|
967
|
+
with open(file_path, "w", newline="", encoding="utf-8") as f:
|
|
968
|
+
writer = csv.writer(f)
|
|
969
|
+
writer.writerow(["File", "Sheet / Position", "Matched Row / Line"])
|
|
970
|
+
for row in range(row_count):
|
|
971
|
+
file_item = self.table.item(row, 0)
|
|
972
|
+
sheet_item = self.table.item(row, 1)
|
|
973
|
+
content_item = self.table.item(row, 2)
|
|
974
|
+
writer.writerow([
|
|
975
|
+
file_item.text() if file_item else "",
|
|
976
|
+
sheet_item.text() if sheet_item else "",
|
|
977
|
+
content_item.text() if content_item else "",
|
|
978
|
+
])
|
|
979
|
+
|
|
980
|
+
QMessageBox.information(
|
|
981
|
+
self, "Export Successful", f"Successfully exported {row_count} rows to:\n{file_path}"
|
|
982
|
+
)
|
|
983
|
+
except Exception as e:
|
|
984
|
+
QMessageBox.critical(self, "Export Error", f"Failed to export CSV:\n{e}")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def main():
|
|
988
|
+
if not PYSIDE_AVAILABLE:
|
|
989
|
+
print(
|
|
990
|
+
"Error: 'PySide6' is required for TDsearch GUI.\n"
|
|
991
|
+
"Please install it using:\n"
|
|
992
|
+
" pip install PySide6",
|
|
993
|
+
file=sys.stderr,
|
|
994
|
+
)
|
|
995
|
+
sys.exit(1)
|
|
996
|
+
|
|
997
|
+
app = QApplication(sys.argv)
|
|
998
|
+
window = TDSearchGUI()
|
|
999
|
+
window.show()
|
|
1000
|
+
sys.exit(app.exec())
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
if __name__ == "__main__":
|
|
1004
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tdsearch
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Desktop (GUI) for Tabular Data Search powered by xlsxgrep.
|
|
5
|
+
Home-page: https://github.com/zazuum/TDsearch
|
|
6
|
+
Author: Ivan Cvitic
|
|
7
|
+
Author-email: cviticivan@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/zazuum/TDsearch
|
|
10
|
+
Project-URL: Tracker, https://github.com/zazuum/TDsearch/issues
|
|
11
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: xlsxgrep==0.0.34
|
|
24
|
+
Requires-Dist: PySide6>=6.0.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# TDsearch (Tabular Data Search)
|
|
28
|
+
|
|
29
|
+
**TDsearch** is a desktop GUI application designed to search text across spreadsheet files including XLSX, XLS, XLSM, CSV, TSV, and ODS formats.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
- **GUI Interface:** Modern PySide6 interface with file and folder browsers.
|
|
33
|
+
- **Multiprocessing Support:** Multi-core parallel search execution.
|
|
34
|
+
- **Regex Support:** Python regex and POSIX Extended Regular Expressions (-E).
|
|
35
|
+
- **Search Options:** Ignore case (-i), fixed strings (-F), word match (-w), column search mode.
|
|
36
|
+
- **CSV Export:** Export structured search results directly to CSV.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
```sh
|
|
40
|
+
pip install tdsearch
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Launch
|
|
44
|
+
```sh
|
|
45
|
+
tdsearch
|
|
46
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.cfg
|
|
5
|
+
setup.py
|
|
6
|
+
tdsearch/__init__.py
|
|
7
|
+
tdsearch/app.py
|
|
8
|
+
tdsearch.egg-info/PKG-INFO
|
|
9
|
+
tdsearch.egg-info/SOURCES.txt
|
|
10
|
+
tdsearch.egg-info/dependency_links.txt
|
|
11
|
+
tdsearch.egg-info/entry_points.txt
|
|
12
|
+
tdsearch.egg-info/requires.txt
|
|
13
|
+
tdsearch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tdsearch
|