kalle07 commited on
Commit
9433615
·
verified ·
1 Parent(s): 5dac36c

Upload 3 files

Browse files
.gitattributes CHANGED
@@ -35,3 +35,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  snippet_extracor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
37
  snippet_extractor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  snippet_extracor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
37
  snippet_extractor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
38
+ snippet_extractor_v04alpha.exe filter=lfs diff=lfs merge=lfs -text
build_v04alpha.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import subprocess
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Define the entry point (your main script)
7
+ entry_point = "snippet_extractor_v04alpha.py"
8
+
9
+ # Build command with PyInstaller arguments
10
+ cmd = [
11
+ sys.executable,
12
+ "-m", "PyInstaller",
13
+ "--onefile",
14
+ "--noconfirm",
15
+ "--clean",
16
+ "--noconsole",
17
+ #"--console", # No console window (important for GUI applications)
18
+ "--hidden-import", "wx",
19
+ #"--hidden-import", "numpy",
20
+ "--hidden-import", "rapidfuzz",
21
+ "--hidden-import", "typing_extensions",
22
+ ]
23
+
24
+ # Add the entry point
25
+ cmd.append(entry_point)
26
+
27
+ # Execute the build command
28
+ try:
29
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
30
+ print("Compilation completed successfully.")
31
+
32
+ # Show any warnings or info from PyInstaller
33
+ if result.stderr:
34
+ for line in result.stderr.split('\n'):
35
+ if line.strip() and not line.startswith('['):
36
+ print(f"PyInstaller: {line}")
37
+
38
+ except subprocess.CalledProcessError as e:
39
+ print(f"Error during compilation: {e}")
40
+ print("Stderr:", e.stderr)
41
+
42
+ except FileNotFoundError:
43
+ print("PyInstaller not found. Please install it with 'pip install pyinstaller'")
44
+
snippet_extractor_v04alpha.exe ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cd4aad6b1589d1f23250119bda6004336f8af6e2afe773ba2dc821f6fa388d71
3
+ size 26207008
snippet_extractor_v04alpha.py ADDED
@@ -0,0 +1,1074 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import wx
2
+ import os
3
+ import re
4
+ import json
5
+ import threading
6
+ from pathlib import Path
7
+ from rapidfuzz import fuzz, process
8
+ from typing import List, Tuple, Optional, Match, Dict, Set
9
+ import logging
10
+
11
+
12
+ # --- Configure logging ---
13
+ # Create a custom logger
14
+ logger = logging.getLogger(__name__)
15
+ logger.setLevel(logging.DEBUG) # Default to info level
16
+
17
+ # Create handler and set level
18
+ handler = logging.StreamHandler()
19
+ handler.setLevel(logging.INFO)
20
+
21
+ # Create formatter
22
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
23
+ handler.setFormatter(formatter)
24
+
25
+ # Add handler to logger
26
+ logger.addHandler(handler)
27
+
28
+
29
+ # --- Engine ---
30
+
31
+ class TextProcessor:
32
+ def __init__(self, file_path: str):
33
+ self.file_path = Path(file_path)
34
+ self.text_bytes: Optional[bytes] = None # Explicit type annotation
35
+ self.decoded_text: Optional[str] = None
36
+ self.char_to_byte: Optional[List[int]] = None
37
+ self.load_and_process_file()
38
+
39
+ def load_and_process_file(self):
40
+ try:
41
+ logger.debug(f"Loading file: {self.file_path}")
42
+ self.text_bytes = self.file_path.read_bytes()
43
+ self.decoded_text = self.text_bytes.decode("utf-8", errors="surrogateescape")
44
+ self._build_char_to_byte_mapping()
45
+ except Exception as e:
46
+ raise RuntimeError(f"Failed to read file {self.file_path}: {str(e)}")
47
+
48
+ def _build_char_to_byte_mapping(self):
49
+ if self.decoded_text is None:
50
+ return # Safety check
51
+
52
+ logger.debug("Building character-to-byte mapping")
53
+ self.char_to_byte = [0]
54
+ for ch in self.decoded_text:
55
+ self.char_to_byte.append(self.char_to_byte[-1] + len(ch.encode("utf-8", errors="surrogateescape")))
56
+
57
+
58
+ class Match:
59
+ def __init__(self, pattern: str, text: str, start_char: int, end_char: int):
60
+ self.pattern = pattern
61
+ self.text = text
62
+ self.start_char = start_char
63
+ self.end_char = end_char
64
+ self.byte_start: Optional[int] = None
65
+ self.byte_end: Optional[int] = None
66
+
67
+ def set_byte_positions(self, char_to_byte_map: List[int]) -> None:
68
+ """Convert character positions to byte positions using the mapping."""
69
+ if char_to_byte_map and len(char_to_byte_map) > self.start_char:
70
+ self.byte_start = char_to_byte_map[self.start_char]
71
+ if char_to_byte_map and len(char_to_byte_map) > self.end_char:
72
+ self.byte_end = char_to_byte_map[self.end_char]
73
+
74
+
75
+
76
+ class SnippetExtractor:
77
+ # Pre-compiled regex patterns for performance
78
+ _regex_cache: dict[str, str] = {}
79
+
80
+ @staticmethod
81
+ def wildcards_to_regex(pattern: str) -> str:
82
+ """
83
+ Convert wildcard pattern to regex with caching.
84
+ - '?' → matches exactly one character of any type
85
+ - '*' → matches zero or more non-whitespace chars
86
+ """
87
+ try:
88
+ # Use cache for better performance
89
+ if pattern in SnippetExtractor._regex_cache:
90
+ return SnippetExtractor._regex_cache[pattern]
91
+
92
+ logger.debug(f"Converting wildcard pattern to regex: {pattern}")
93
+ regex_parts = []
94
+ i = 0
95
+ while i < len(pattern):
96
+ ch = pattern[i]
97
+ if ch == '?':
98
+ regex_parts.append('.')
99
+ i += 1
100
+ elif ch == '*':
101
+ regex_parts.append(r'(?:\S*)')
102
+ i += 1
103
+ else:
104
+ regex_parts.append(re.escape(ch))
105
+ i += 1
106
+
107
+ result = "".join(regex_parts)
108
+ SnippetExtractor._regex_cache[pattern] = result
109
+ logger.debug(f"Converted pattern '{pattern}' to regex: {result}")
110
+ return result
111
+ except Exception as e:
112
+ raise RuntimeError(f"Failed to convert wildcard pattern '{pattern}' to regex: {str(e)}")
113
+
114
+
115
+ @staticmethod
116
+ def expand_to_word_boundaries(text: str, start_char: int, end_char: int, pattern: str):
117
+ """
118
+ Expand match boundaries depending on '*' position.
119
+ """
120
+ try:
121
+ # Exact match for '?' only patterns
122
+ if '?' in pattern and '*' not in pattern:
123
+ return text[start_char:end_char], start_char, end_char
124
+
125
+ expanded_start = start_char
126
+ expanded_end = end_char
127
+
128
+ logger.debug(f"Expanding boundaries for pattern '{pattern}'")
129
+
130
+ if '*' in pattern:
131
+ if pattern.startswith('*') and not pattern.endswith('*'):
132
+ # expand LEFT until whitespace - optimized with backward search
133
+ while expanded_start > 0 and not text[expanded_start - 1].isspace():
134
+ expanded_start -= 1
135
+
136
+ elif pattern.endswith('*') and not pattern.startswith('*'):
137
+ # expand RIGHT until whitespace - optimized forward search
138
+ while expanded_end < len(text) and not text[expanded_end].isspace():
139
+ expanded_end += 1
140
+
141
+ else:
142
+ # '*' is inside → expand both sides until visible character
143
+ if expanded_start > 0:
144
+ expanded_start -= 1
145
+ if expanded_end < len(text):
146
+ expanded_end += 1
147
+
148
+ return text[expanded_start:expanded_end], expanded_start, expanded_end
149
+
150
+ except Exception as e:
151
+ raise RuntimeError(f"Failed to expand word boundaries for pattern '{pattern}': {str(e)}")
152
+
153
+
154
+ @staticmethod
155
+ def find_matches(patterns, decoded_text: str, char_to_byte_map):
156
+ """
157
+ Find all matches. These are also passed on to fuzzy match.
158
+ """
159
+ try:
160
+ logger.debug("Finding wildcard matches")
161
+ matches = []
162
+
163
+ # Pre-compile all patterns once - cached version
164
+ compiled_patterns = {}
165
+ for pattern in patterns:
166
+ if not pattern:
167
+ continue
168
+ if '*' in pattern or '?' in pattern:
169
+ regex_pattern = SnippetExtractor.wildcards_to_regex(pattern)
170
+ compiled_patterns[pattern] = re.compile(regex_pattern, re.IGNORECASE | re.DOTALL)
171
+ else:
172
+ escaped_pattern = re.escape(pattern)
173
+ regex_pattern = r'\b' + escaped_pattern + r'\b'
174
+ compiled_patterns[pattern] = re.compile(regex_pattern, re.IGNORECASE)
175
+
176
+ for pattern, compiled_pattern in compiled_patterns.items():
177
+ try:
178
+ # Check stop event before each iteration
179
+ for match in compiled_pattern.finditer(decoded_text):
180
+ start_pos, end_pos = match.start(), match.end()
181
+ match_text = decoded_text[start_pos:end_pos]
182
+
183
+ if '*' in pattern or '?' in pattern:
184
+ expanded_match_text, expanded_start, expanded_end = SnippetExtractor.expand_to_word_boundaries(
185
+ decoded_text, start_pos, end_pos, pattern
186
+ )
187
+ match_text = expanded_match_text
188
+ start_pos = expanded_start
189
+ end_pos = expanded_end
190
+
191
+ match_obj = Match(pattern, match_text, start_pos, end_pos)
192
+ match_obj.set_byte_positions(char_to_byte_map)
193
+ matches.append(match_obj)
194
+ except re.error as e:
195
+ raise RuntimeError(f"Regex compilation error for pattern '{pattern}': {str(e)}")
196
+
197
+ logger.debug(f"Found {len(matches)} wildcard matches")
198
+ return matches
199
+ except Exception as e:
200
+ raise RuntimeError(f"Failed to find matches: {str(e)}")
201
+
202
+
203
+ @staticmethod
204
+ def filter_by_distance(matches, distance: int, buzzwords):
205
+ """
206
+ filter matches by distance limit given by user input.
207
+ """
208
+ try:
209
+ logger.debug("Filtering matches by distance")
210
+ if not matches:
211
+ return []
212
+
213
+ # Use sets for faster membership checks and avoid redundant lookups
214
+ pattern_positions: dict[str, set[tuple[int, int]]] = {word: set() for word in buzzwords}
215
+ for m in matches:
216
+ if m.pattern in pattern_positions:
217
+ pattern_positions[m.pattern].add((m.start_char, m.end_char))
218
+
219
+ if any(not pos_set for pos_set in pattern_positions.values()):
220
+ return []
221
+
222
+ combined_spans = []
223
+ first_word = list(buzzwords)[0]
224
+
225
+ for start1, end1 in pattern_positions[first_word]:
226
+ span_candidates = [(start1, end1)]
227
+ for other_word in buzzwords:
228
+ if other_word == first_word:
229
+ continue
230
+ best_match = None
231
+ min_distance = float('inf')
232
+
233
+ # Direct set iteration - much faster than list lookup
234
+ for start2, end2 in pattern_positions[other_word]:
235
+ dist = abs(start1 - start2)
236
+ if dist <= distance and dist < min_distance:
237
+ min_distance = dist
238
+ best_match = (start2, end2)
239
+
240
+ if best_match:
241
+ span_candidates.append(best_match)
242
+
243
+ if len(span_candidates) == len(buzzwords):
244
+ min_pos = min(s for s, _ in span_candidates)
245
+ max_pos = max(e for _, e in span_candidates)
246
+ combined_spans.append((min_pos, max_pos))
247
+
248
+ logger.debug(f"After distance filtering: {len(combined_spans)} matches")
249
+ return combined_spans
250
+ except Exception as e:
251
+ raise RuntimeError(f"Failed to filter by distance: {str(e)}")
252
+
253
+ @staticmethod
254
+ def extract_snippets(matches, snippet_size, pre_ratio, post_ratio, decoded_text):
255
+ try:
256
+ logger.debug("Extracting snippets for wildcard matches")
257
+ snippets = []
258
+ for start, end in matches:
259
+ pre_chars = int(snippet_size * pre_ratio)
260
+ post_chars = int(snippet_size * post_ratio)
261
+ snippet_start = max(0, start - pre_chars)
262
+ snippet_end = min(len(decoded_text), end + post_chars)
263
+ snippets.append((snippet_start, snippet_end))
264
+ return snippets
265
+ except Exception as e:
266
+ raise RuntimeError(f"Failed to extract snippets: {str(e)}")
267
+
268
+ @staticmethod
269
+ def merge_snippets(snippets):
270
+ try:
271
+ logger.debug("Merging wildcard snippets")
272
+ if not snippets:
273
+ return [], 0
274
+
275
+ total_snippets = len(snippets)
276
+
277
+ # Sort once instead of repeatedly during merging
278
+ sorted_snippets = sorted(snippets, key=lambda x: x[0])
279
+ merged = [sorted_snippets[0]]
280
+
281
+ for current in sorted_snippets[1:]:
282
+ last_end = merged[-1][1]
283
+ if current[0] <= last_end:
284
+ # Fast merge - no need to check all previous ones
285
+ merged[-1] = (merged[-1][0], max(last_end, current[1]))
286
+ else:
287
+ merged.append(current)
288
+
289
+ logger.debug(f"Merged snippets: {len(merged)} from {total_snippets}")
290
+ return merged, total_snippets
291
+ except Exception as e:
292
+ raise RuntimeError(f"Failed to merge snippets: {str(e)}")
293
+
294
+
295
+ # ----------
296
+ # Fuzzy part
297
+ # ----------
298
+
299
+ @staticmethod
300
+ def find_fuzzy_matches(decoded_text: str, wildcard_matches: List[Match], threshold: float, stop_event=None):
301
+ """
302
+ Search the entire text using matches from wildcard search as fuzzily searched words.
303
+ Returns list of tuples (match_start, match_end, score, original_word) where score >= threshold.
304
+ """
305
+ try:
306
+ logger.debug("Starting fuzzy matching")
307
+ fuzzy_results: List[Tuple[int, int, float, str]] = []
308
+
309
+ # Get all unique texts from wildcard matches to use as buzzwords
310
+ buzzwords = [match.text for match in wildcard_matches if match.text.strip()]
311
+
312
+ if not buzzwords:
313
+ logger.debug("No buzzwords found for fuzzy matching")
314
+ return fuzzy_results
315
+
316
+ logger.debug(f"Using {len(buzzwords)} buzzwords for fuzzy matching")
317
+
318
+ # Use rapidfuzz.process.extract for efficient fuzzy matching
319
+ words = decoded_text.split()
320
+ processed_words = []
321
+
322
+ # Create a list of (word, start_pos, end_pos) tuples to track positions
323
+ current_pos = 0
324
+ for word in words:
325
+ if stop_event and stop_event.is_set():
326
+ raise RuntimeError("Fuzzy search was aborted")
327
+
328
+ # Find exact position of this word in original text
329
+ try:
330
+ pos = decoded_text.index(word, current_pos)
331
+ processed_words.append((word, pos, pos + len(word)))
332
+ current_pos = pos + len(word)
333
+ except ValueError:
334
+ # Word not found - skip it
335
+ continue
336
+
337
+ # For each word in the document, check fuzzy matches against our buzzwords
338
+ for word, start_pos, end_pos in processed_words:
339
+ if stop_event and stop_event.is_set():
340
+ raise RuntimeError("Fuzzy search was aborted")
341
+
342
+ # Find best match among buzzwords using rapidfuzz
343
+ try:
344
+ # Get top match with score >= threshold
345
+ matches = process.extract(
346
+ word,
347
+ buzzwords,
348
+ limit=1,
349
+ scorer=fuzz.ratio,
350
+ score_cutoff=threshold
351
+ )
352
+
353
+ if matches and len(matches) > 0:
354
+ best_match_text, score, _ = matches[0]
355
+ # Add the position of this match in original text + the actual word that was matched
356
+ fuzzy_results.append((start_pos, end_pos, score, word))
357
+
358
+ except Exception as e:
359
+ # Continue with other words if one fails
360
+ logger.warning(f"Fuzzy matching failed for word '{word}': {str(e)}")
361
+ continue
362
+
363
+ logger.debug(f"Found {len(fuzzy_results)} fuzzy matches")
364
+ return fuzzy_results
365
+
366
+ except Exception as e:
367
+ raise RuntimeError(f"Failed to find fuzzy matches: {str(e)}")
368
+
369
+
370
+ @staticmethod
371
+ def filter_by_distance_fuzzy(fuzzy_matches, distance_threshold):
372
+ """
373
+ Filter fuzzy matches requiring all buzzwords within distance threshold.
374
+ Groups matching words together and only keeps groups where all required
375
+ buzzwords appear within the specified distance.
376
+
377
+ Args:
378
+ fuzzy_matches: List of tuples (start_pos, end_pos, score, original_word)
379
+ distance_threshold: Maximum character distance between matches
380
+
381
+ Returns:
382
+ List of filtered fuzzy match tuples
383
+ """
384
+ try:
385
+ logger.debug("Filtering fuzzy matches by distance")
386
+ if not fuzzy_matches:
387
+ return []
388
+
389
+ # Group matches by their original word (buzzword)
390
+ word_groups = {}
391
+ for start, end, score, word in fuzzy_matches:
392
+ if word not in word_groups:
393
+ word_groups[word] = []
394
+ word_groups[word].append((start, end, score))
395
+
396
+ logger.debug(f"Processing {len(word_groups)} unique words from fuzzy matches")
397
+ for word, positions in word_groups.items():
398
+ logger.debug(f" Word '{word}': {len(positions)} matches at positions {[pos[0] for pos in positions]}")
399
+
400
+ # Get all buzzwords that were actually found
401
+ found_buzzwords = list(word_groups.keys())
402
+
403
+ if len(found_buzzwords) < 2:
404
+ logger.debug("Only one unique word found - returning all matches")
405
+ return fuzzy_matches
406
+
407
+ # For multiple words, create sliding windows to find valid groups
408
+ results = []
409
+
410
+ # Sort all positions by start position to make grouping easier
411
+ all_positions = []
412
+ for word, pos_list in word_groups.items():
413
+ for start, end, score in pos_list:
414
+ all_positions.append((start, end, score, word))
415
+
416
+ all_positions.sort(key=lambda x: x[0]) # Sort by start position
417
+
418
+ logger.debug(f"Total positions to process: {len(all_positions)}")
419
+
420
+ # Try to find groups where multiple buzzwords appear within distance
421
+ i = 0
422
+ while i < len(all_positions):
423
+ current_start = all_positions[i][0]
424
+ current_end = all_positions[i][1]
425
+
426
+ # Create a window around this position
427
+ window_end = current_start + distance_threshold
428
+
429
+ # Collect all words in this window
430
+ window_words = {}
431
+ j = i
432
+ while j < len(all_positions) and all_positions[j][0] <= window_end:
433
+ pos_start, pos_end, score, word = all_positions[j]
434
+ if word not in window_words:
435
+ window_words[word] = []
436
+ window_words[word].append((pos_start, pos_end, score))
437
+ j += 1
438
+
439
+ # Check if we have matches for ALL required buzzwords
440
+ if len(window_words) >= 2: # At least two different words found together
441
+ logger.debug(f"Found group with {len(window_words)} words in range [{current_start}, {window_end}]")
442
+ for word, positions in window_words.items():
443
+ logger.debug(f" Word '{word}': {[pos[0] for pos in positions]}")
444
+
445
+ # Add all matches from this valid window
446
+ for word, positions in window_words.items():
447
+ for start, end, score in positions:
448
+ results.append((start, end, score, word))
449
+ else:
450
+ logger.debug(f"Window [{current_start}, {window_end}] only had {len(window_words)} unique words")
451
+
452
+ i = j
453
+
454
+ # Remove duplicates while preserving order
455
+ seen = set()
456
+ final_results = []
457
+ for item in results:
458
+ if item not in seen:
459
+ seen.add(item)
460
+ final_results.append(item)
461
+
462
+ logger.debug(f"Final filtered results count: {len(final_results)}")
463
+ return final_results
464
+
465
+ except Exception as e:
466
+ raise RuntimeError(f"Failed to filter fuzzy matches by distance: {str(e)}")
467
+
468
+
469
+
470
+ @staticmethod
471
+ def extract_snippets_fuzzy(matches, snippet_size, pre_ratio, post_ratio, decoded_text):
472
+ """
473
+ Extract snippets from fuzzy matches.
474
+ """
475
+ try:
476
+ logger.debug("Extracting snippets for fuzzy matches")
477
+ snippets = []
478
+ for start, end, score, original_word in matches:
479
+ # Apply ratio-based padding to include more context
480
+ pre_chars = int(snippet_size * pre_ratio)
481
+ post_chars = int(snippet_size * post_ratio)
482
+ snippet_start = max(0, start - pre_chars)
483
+ snippet_end = min(len(decoded_text), end + post_chars)
484
+
485
+ snippets.append((snippet_start, snippet_end, score, original_word))
486
+ return snippets
487
+ except Exception as e:
488
+ raise RuntimeError(f"Failed to extract fuzzy snippets: {str(e)}")
489
+
490
+
491
+
492
+ @staticmethod
493
+ def merge_snippets_fuzzy(snippets):
494
+ """
495
+ Merge overlapping or adjacent fuzzy snippets.
496
+ """
497
+ try:
498
+ logger.debug("Merging fuzzy snippets")
499
+ if not snippets:
500
+ return [], 0
501
+
502
+ total_snippets = len(snippets)
503
+
504
+ # Sort by start position
505
+ sorted_snippets = sorted(snippets, key=lambda x: x[0])
506
+ merged = [sorted_snippets[0]]
507
+
508
+ for current in sorted_snippets[1:]:
509
+ last_end = merged[-1][1]
510
+
511
+ if current[0] <= last_end:
512
+ # Merge overlapping or adjacent snippets
513
+ new_start = merged[-1][0]
514
+ new_end = max(last_end, current[1])
515
+
516
+ # Update the score to be average of both scores (or keep highest)
517
+ avg_score = (merged[-1][2] + current[2]) / 2.0
518
+
519
+ merged[-1] = (new_start, new_end, avg_score, merged[-1][3]) # Keep original word from first
520
+ else:
521
+ merged.append(current)
522
+
523
+ logger.debug(f"Merged fuzzy snippets: {len(merged)} from {total_snippets}")
524
+ return merged, total_snippets
525
+ except Exception as e:
526
+ raise RuntimeError(f"Failed to merge fuzzy snippets: {str(e)}")
527
+
528
+
529
+ # --- Main search function ---
530
+
531
+ def run_search_for_file(file_path: str, config: dict, stop_event: threading.Event):
532
+ """
533
+ Run search for a single file. Writes output_snippets.txt and output_fuzzy_snippets.txt.
534
+ Returns (wildcard_text, fuzzy_text) strings for UI display.
535
+ Optimized version with faster operations.
536
+ """
537
+ try:
538
+ processor = TextProcessor(file_path)
539
+
540
+ if processor.text_bytes is None or processor.char_to_byte is None:
541
+ raise RuntimeError("Failed to load file properly")
542
+
543
+ buzzwords = [bw for bw in config.get("buzzwords", []) if bw.strip()]
544
+
545
+ # Use set for filter_by_distance membership but keep list for order preservation
546
+ buzzwords_set = list(dict.fromkeys(buzzwords)) # unique preserving order
547
+
548
+ # wildcard-part - optimized
549
+ all_matches = SnippetExtractor.find_matches(
550
+ buzzwords_set,
551
+ processor.decoded_text,
552
+ processor.char_to_byte
553
+ )
554
+
555
+ if config.get("search_type", "AND") == "AND":
556
+ final_matches = SnippetExtractor.filter_by_distance(
557
+ all_matches,
558
+ config.get("distance_match", 100),
559
+ buzzwords_set
560
+ )
561
+ else:
562
+ final_matches = [(m.start_char, m.end_char) for m in all_matches]
563
+
564
+ snippets = SnippetExtractor.extract_snippets(
565
+ final_matches,
566
+ config.get("snippet_size", 2000),
567
+ config.get("pre_ratio", 0.3),
568
+ config.get("post_ratio", 0.7),
569
+ processor.decoded_text
570
+ )
571
+
572
+ merged_snippets, total_snippets = SnippetExtractor.merge_snippets(snippets)
573
+
574
+ # Count wildcard metrics
575
+ all_wildcard_matches = len(all_matches)
576
+ merged_wildcard_snippets = len(merged_snippets)
577
+
578
+ # Calculate characters in merged snippets for wildcard
579
+ total_wildcard_chars = 0
580
+ for start, end in merged_snippets:
581
+ if stop_event.is_set():
582
+ raise RuntimeError("Search was aborted")
583
+
584
+ s_b = processor.char_to_byte[start]
585
+ e_b = processor.char_to_byte[end]
586
+ snippet_bytes = processor.text_bytes[s_b:e_b]
587
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
588
+ cleaned = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
589
+ total_wildcard_chars += len(cleaned)
590
+
591
+ # Calculate token for wildcard (characters / 4.2)
592
+ wildcard_token = round(total_wildcard_chars / 4.2, 1) if total_wildcard_chars > 0 else 0
593
+
594
+ # Build wildcard textual output - optimized with pre-calculated values
595
+ wildcard_blocks = []
596
+ for idx, (start, end) in enumerate(merged_snippets):
597
+ if stop_event.is_set():
598
+ raise RuntimeError("Search was aborted")
599
+
600
+ s_b = processor.char_to_byte[start]
601
+ e_b = processor.char_to_byte[end]
602
+ snippet_bytes = processor.text_bytes[s_b:e_b]
603
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
604
+ cleaned = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
605
+
606
+ # Find first match
607
+ match_text = None
608
+ byte_start = None
609
+ for m in all_matches:
610
+ if start <= m.start_char and end >= m.end_char:
611
+ match_text = m.text
612
+ byte_start = m.byte_start
613
+ break
614
+
615
+ block = [
616
+ {"Excerpt": idx + 1},
617
+ {"Match Buzzword": match_text},
618
+ {"Position, match_text": byte_start},
619
+ {"Content": cleaned},
620
+ ]
621
+ wildcard_blocks.append(json.dumps(block, ensure_ascii=False, indent=1))
622
+
623
+ wildcard_text = "\n\n".join(wildcard_blocks)
624
+
625
+
626
+
627
+
628
+ # fuzzy part, similar approach like wildcard
629
+ ft = config.get("fuzzy_threshold", 96)
630
+ if not isinstance(ft, (int, float)) or not (0 <= ft <= 100):
631
+ ft = 96.0 # default threshold
632
+
633
+ # Use all wildcard matches as input for fuzzy search
634
+ fuzzy_matches = SnippetExtractor.find_fuzzy_matches(
635
+ processor.decoded_text,
636
+ all_matches,
637
+ ft
638
+ )
639
+
640
+ if config.get("search_type", "AND") == "AND":
641
+ if len(buzzwords) > 1:
642
+ filtered_fuzzy_matches = SnippetExtractor.filter_by_distance_fuzzy(
643
+ fuzzy_matches,
644
+ config.get("distance_match", 100)
645
+ )
646
+ else:
647
+ # fallback to OR behavior when only one buzzword
648
+ filtered_fuzzy_matches = fuzzy_matches
649
+ else:
650
+ filtered_fuzzy_matches = fuzzy_matches
651
+
652
+ # Extract snippets for fuzzy matches
653
+ fuzzy_snippets = SnippetExtractor.extract_snippets_fuzzy(
654
+ filtered_fuzzy_matches,
655
+ config.get("snippet_size", 2000),
656
+ config.get("pre_ratio", 0.3),
657
+ config.get("post_ratio", 0.7),
658
+ processor.decoded_text
659
+ )
660
+
661
+ # Merge fuzzy snippets
662
+ merged_fuzzy_snippets, total_fuzzy_snippets = SnippetExtractor.merge_snippets_fuzzy(fuzzy_snippets)
663
+
664
+ # Count fuzzy metrics
665
+ all_fuzzy_matches = len(fuzzy_matches)
666
+ merged_fuzzy_snippets_count = len(merged_fuzzy_snippets)
667
+
668
+ # Calculate characters in merged snippets for fuzzy
669
+ total_fuzzy_chars = 0
670
+ for start, end, score, original_word in merged_fuzzy_snippets:
671
+ if stop_event.is_set():
672
+ raise RuntimeError("Search was aborted")
673
+
674
+ s_b = processor.char_to_byte[start]
675
+ e_b = processor.char_to_byte[end]
676
+ snippet_bytes = processor.text_bytes[s_b:e_b]
677
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
678
+ cleaned_snippet = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
679
+ total_fuzzy_chars += len(cleaned_snippet)
680
+
681
+ # Calculate token for fuzzy (characters / 4.2)
682
+ fuzzy_token = round(total_fuzzy_chars / 4.2, 1) if total_fuzzy_chars > 0 else 0
683
+
684
+ # Build fuzzy textual output - now with actual matched text and byte positions
685
+ fuzzy_blocks = []
686
+ for idx, (start, end, score, original_word) in enumerate(merged_fuzzy_snippets):
687
+ if stop_event.is_set():
688
+ raise RuntimeError("Search was aborted")
689
+
690
+ s_b = processor.char_to_byte[start]
691
+ e_b = processor.char_to_byte[end]
692
+ snippet_bytes = processor.text_bytes[s_b:e_b]
693
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
694
+ cleaned_snippet = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
695
+
696
+ # Get the actual byte start position of the matched word in the original file
697
+ match_byte_start = None
698
+ for fm in fuzzy_matches: # Use original fuzzy_matches, not filtered_fuzzy_matches
699
+ if fm[3] == original_word and fm[0] >= start and fm[1] <= end:
700
+ # Found the exact fuzzy match that corresponds to this merged snippet
701
+ match_byte_start = processor.char_to_byte[fm[0]]
702
+ break
703
+
704
+ block = [
705
+ {"Excerpt": idx + 1},
706
+ {"Match Buzzword": original_word}, # Show the actual word that was matched
707
+ {"Score": score},
708
+ {"Position": match_byte_start}, # Add byte position to JSON output
709
+ {"Content": cleaned_snippet},
710
+ ]
711
+ fuzzy_blocks.append(json.dumps(block, ensure_ascii=False, indent=1))
712
+
713
+ fuzzy_text = "\n\n".join(fuzzy_blocks)
714
+
715
+ logger.debug("Search completed successfully")
716
+ return wildcard_text, fuzzy_text, all_wildcard_matches, merged_wildcard_snippets, total_wildcard_chars, wildcard_token, all_fuzzy_matches, merged_fuzzy_snippets_count, total_fuzzy_chars, fuzzy_token
717
+ except Exception as e:
718
+ logger.error(f"Search failed for file {file_path}: {str(e)}")
719
+ raise RuntimeError(f"Search failed for file {file_path}: {str(e)}")
720
+
721
+ # ---
722
+ # GUI
723
+ # ---
724
+
725
+ class SearchThread(threading.Thread):
726
+ def __init__(self, paths, config, stop_event, on_complete):
727
+ super().__init__()
728
+ self.paths = paths
729
+ self.config = config
730
+ self.stop_event = stop_event
731
+ self.on_complete = on_complete # callback(wildcard_text, fuzzy_text, finished_ok)
732
+
733
+ def run(self):
734
+ try:
735
+ agg_wild = []
736
+ agg_fuzzy = []
737
+ total_all_wildcard_matches = 0
738
+ total_merged_wildcard_snippets = 0
739
+ total_wildcard_chars = 0
740
+ total_wildcard_tokens = 0
741
+
742
+ total_all_fuzzy_matches = 0
743
+ total_merged_fuzzy_snippets = 0
744
+ total_fuzzy_chars = 0
745
+ total_fuzzy_tokens = 0
746
+
747
+ for p in self.paths:
748
+ if self.stop_event.is_set():
749
+ self.on_complete("", "", False)
750
+ return
751
+ try:
752
+ w, f, all_wildcard_matches, merged_wildcard_snippets, wildcard_chars, wildcard_token, all_fuzzy_matches, merged_fuzzy_snippets, fuzzy_chars, fuzzy_token = run_search_for_file(p, self.config, self.stop_event)
753
+
754
+ # Accumulate totals
755
+ total_all_wildcard_matches += all_wildcard_matches
756
+ total_merged_wildcard_snippets += merged_wildcard_snippets
757
+ total_wildcard_chars += wildcard_chars
758
+ total_wildcard_tokens += wildcard_token
759
+
760
+ total_all_fuzzy_matches += all_fuzzy_matches
761
+ total_merged_fuzzy_snippets += merged_fuzzy_snippets
762
+ total_fuzzy_chars += fuzzy_chars
763
+ total_fuzzy_tokens += fuzzy_token
764
+
765
+ agg_wild.append(w)
766
+ agg_fuzzy.append(f)
767
+ except Exception as e:
768
+ # If one file fails, continue with others but report the error
769
+ if not self.stop_event.is_set(): # Only show error if not aborted
770
+ self.on_complete(f"ERROR processing {p}: {str(e)}", f"ERROR processing {p}: {str(e)}", False)
771
+ return
772
+
773
+ wildcard_text = "\n\n--- FILE BOUNDARY ---\n\n".join(agg_wild)
774
+ fuzzy_text = "\n\n--- FILE BOUNDARY ---\n\n".join(agg_fuzzy)
775
+
776
+ # Create summary strings
777
+ wildcard_summary = f"Wildcard Results (output_snippets.txt): All matches: {total_all_wildcard_matches}, Merged snippets: {total_merged_wildcard_snippets}, Characters in merged: {total_wildcard_chars}, Token: {total_wildcard_tokens}"
778
+ fuzzy_summary = f"Fuzzy Results (output_fuzzy_snippets.txt): All matches: {total_all_fuzzy_matches}, Merged snippets: {total_merged_fuzzy_snippets}, Characters in merged: {total_fuzzy_chars}, Token: {total_fuzzy_tokens}"
779
+
780
+ # Prepend summaries to results
781
+ wildcard_text = wildcard_summary + "\n\n" + wildcard_text if wildcard_text else wildcard_summary
782
+ fuzzy_text = fuzzy_summary + "\n\n" + fuzzy_text if fuzzy_text else fuzzy_summary
783
+
784
+ self.on_complete(wildcard_text, fuzzy_text, True)
785
+ except Exception as e:
786
+ # Handle exceptions in the thread itself
787
+ self.on_complete(f"THREAD ERROR: {str(e)}", f"THREAD ERROR: {str(e)}", False)
788
+
789
+ class MainFrame(wx.Frame):
790
+ def __init__(self):
791
+ super().__init__(None, title="Text Search by Sevenof9 (v4_alpha)", size=(1200, 1000))
792
+ panel = wx.Panel(self)
793
+
794
+ # Top: file / dir pickers and right-side label for chosen path
795
+ top_sizer = wx.BoxSizer(wx.HORIZONTAL)
796
+ self.file_picker = wx.FilePickerCtrl(panel, style=wx.FLP_OPEN | wx.FLP_FILE_MUST_EXIST)
797
+ self.dir_picker = wx.DirPickerCtrl(panel)
798
+ self.path_label = wx.StaticText(panel, label="No file/folder selected")
799
+
800
+ top_sizer.Add(self.file_picker, 0, wx.ALL | wx.ALIGN_LEFT, 4)
801
+ top_sizer.Add(self.dir_picker, 0, wx.ALL | wx.ALIGN_LEFT, 4)
802
+ top_sizer.Add(self.path_label, 0, wx.ALL | wx.ALIGN_LEFT, 6)
803
+
804
+ # Middle: left = buzzwords (4 fields with AND/OR buttons between), right = controls/config
805
+ middle_sizer = wx.BoxSizer(wx.HORIZONTAL)
806
+
807
+ # Left: buzzwords area
808
+ buzz_sizer = wx.BoxSizer(wx.VERTICAL)
809
+ self.buzz_inputs = []
810
+ self.toggle_buttons = []
811
+ for i in range(4):
812
+ txt = wx.TextCtrl(panel, size=(250, -1))
813
+ self.buzz_inputs.append(txt)
814
+ buzz_sizer.Add(txt, 0, wx.ALL | wx.ALIGN_LEFT, 2)
815
+ if i < 3:
816
+ btn = wx.Button(panel, label="AND", size=(80, 24))
817
+ btn.Bind(wx.EVT_BUTTON, self.on_toggle)
818
+ self.toggle_buttons.append(btn)
819
+ buzz_sizer.Add(btn, 0, wx.ALL | wx.ALIGN_LEFT, 2)
820
+
821
+ middle_sizer.Add(buzz_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
822
+
823
+ # Right: controls and config
824
+ ctrl_sizer = wx.BoxSizer(wx.VERTICAL)
825
+
826
+ # Start / Abort
827
+ self.start_button = wx.Button(panel, label="Start Search")
828
+ self.abort_button = wx.Button(panel, label="Abort")
829
+ self.abort_button.Disable()
830
+ self.start_button.Bind(wx.EVT_BUTTON, self.on_start)
831
+ self.abort_button.Bind(wx.EVT_BUTTON, self.on_abort)
832
+ ctrl_sizer.Add(self.start_button, 0, wx.ALL | wx.ALIGN_LEFT, 4)
833
+ ctrl_sizer.Add(self.abort_button, 0, wx.ALL | wx.ALIGN_LEFT, 4)
834
+
835
+ # Config fields
836
+ self.cfg_fields = {}
837
+ defaults = [("snippet_size", "2000"),
838
+ ("pre_ratio", "0.3"),
839
+ ("post_ratio", "0.7"),
840
+ ("distance_match", "300"),
841
+ ("fuzzy_threshold", "96")]
842
+ for label, val in defaults:
843
+ row = wx.BoxSizer(wx.HORIZONTAL)
844
+ lbl = wx.StaticText(panel, label=label + ":")
845
+ fld = wx.TextCtrl(panel, value=val, size=(50, -1))
846
+ # Bind focus event for validation
847
+ fld.Bind(wx.EVT_KILL_FOCUS, self.on_field_focus_lost)
848
+ row.Add(lbl, 0, wx.ALL | wx.ALIGN_LEFT, 2)
849
+ row.Add(fld, 0, wx.ALL | wx.ALIGN_LEFT, 2)
850
+ ctrl_sizer.Add(row, 0, wx.ALL | wx.ALIGN_LEFT, 2)
851
+ self.cfg_fields[label] = fld
852
+
853
+ middle_sizer.Add(ctrl_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
854
+
855
+ # Bottom: results (wildcard and fuzzy) across full width
856
+ result_sizer = wx.BoxSizer(wx.VERTICAL)
857
+
858
+ # Add the summary labels BEFORE the text controls
859
+ self.wildcard_summary_label = wx.StaticText(panel, label="Wildcard Results:")
860
+ result_sizer.Add(self.wildcard_summary_label, 0, wx.ALL | wx.ALIGN_LEFT, 2)
861
+
862
+ self.wildcard_box = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(-1, 220))
863
+ result_sizer.Add(self.wildcard_box, 1, wx.EXPAND | wx.ALL, 4)
864
+
865
+ self.fuzzy_summary_label = wx.StaticText(panel, label="Fuzzy Results:")
866
+ result_sizer.Add(self.fuzzy_summary_label, 0, wx.ALL | wx.ALIGN_LEFT, 2)
867
+
868
+ self.fuzzy_box = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(-1, 220))
869
+ result_sizer.Add(self.fuzzy_box, 1, wx.EXPAND | wx.ALL, 4)
870
+
871
+ # Main vertical layout using only horizontal alignment flags where appropriate
872
+ main_sizer = wx.BoxSizer(wx.VERTICAL)
873
+ main_sizer.Add(top_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
874
+ main_sizer.Add(middle_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
875
+ main_sizer.Add(result_sizer, 1, wx.EXPAND | wx.ALL, 6)
876
+
877
+ panel.SetSizer(main_sizer)
878
+
879
+ # Events
880
+ self.file_picker.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_path_change)
881
+ self.dir_picker.Bind(wx.EVT_DIRPICKER_CHANGED, self.on_path_change)
882
+
883
+ # Thread controls
884
+ self.worker = None
885
+ self.stop_event = threading.Event()
886
+
887
+ def on_field_focus_lost(self, evt):
888
+ """Validate all fields when any field loses focus"""
889
+ self.validate_all_fields()
890
+ evt.Skip() # Allow normal processing to continue
891
+
892
+ def validate_all_fields(self):
893
+ """Validate all configuration fields and enforce dependencies"""
894
+ try:
895
+ # Get current values
896
+ snippet_size_val = self.cfg_fields["snippet_size"].GetValue().strip()
897
+ pre_ratio_val = self.cfg_fields["pre_ratio"].GetValue().strip()
898
+ post_ratio_val = self.cfg_fields["post_ratio"].GetValue().strip()
899
+ distance_match_val = self.cfg_fields["distance_match"].GetValue().strip()
900
+ fuzzy_threshold_val = self.cfg_fields["fuzzy_threshold"].GetValue().strip()
901
+
902
+ # Default values if empty
903
+ snippet_size_val = snippet_size_val if snippet_size_val else "2000"
904
+ pre_ratio_val = pre_ratio_val if pre_ratio_val else "0.3"
905
+ post_ratio_val = post_ratio_val if post_ratio_val else "0.7"
906
+ distance_match_val = distance_match_val if distance_match_val else "300"
907
+ fuzzy_threshold_val = fuzzy_threshold_val if fuzzy_threshold_val else "96"
908
+
909
+ # Validate and process each field
910
+ # snippet_size: min=0, max=999999, round to integer
911
+ snippet_size = int(float(snippet_size_val)) if snippet_size_val else 2000
912
+ snippet_size = max(0, min(999999, snippet_size))
913
+
914
+ # pre_ratio: min=0.1, max=0.9, 1 decimal place
915
+ pre_ratio = round(float(pre_ratio_val), 1) if pre_ratio_val else 0.3
916
+ pre_ratio = max(0.1, min(0.9, pre_ratio))
917
+
918
+ # post_ratio: min=0.1, max=0.9, 1 decimal place
919
+ post_ratio = round(float(post_ratio_val), 1) if post_ratio_val else 0.7
920
+ post_ratio = max(0.1, min(0.9, post_ratio))
921
+
922
+ # Ensure pre + post = 1 (adjust one to maintain sum)
923
+ total = pre_ratio + post_ratio
924
+ if abs(total - 1.0) > 0.001: # Allow small floating point differences
925
+ # Adjust post_ratio to make the sum equal to 1.0
926
+ post_ratio = round(1.0 - pre_ratio, 1)
927
+ self.cfg_fields["post_ratio"].SetValue(str(post_ratio))
928
+
929
+ # distance_match: min=0, max=snippet_size, round to integer
930
+ distance_match = int(float(distance_match_val)) if distance_match_val else 300
931
+ distance_match = max(0, min(snippet_size, distance_match))
932
+
933
+ # If snippet_size < distance_match, adjust snippet_size to match
934
+ if snippet_size < distance_match:
935
+ snippet_size = distance_match
936
+ self.cfg_fields["snippet_size"].SetValue(str(snippet_size))
937
+
938
+ # fuzzy_threshold: min=1, max=100, round to integer
939
+ fuzzy_threshold = int(float(fuzzy_threshold_val)) if fuzzy_threshold_val else 96
940
+ fuzzy_threshold = max(1, min(100, fuzzy_threshold))
941
+
942
+ # Apply validated values back to fields
943
+ self.cfg_fields["snippet_size"].SetValue(str(snippet_size))
944
+ self.cfg_fields["pre_ratio"].SetValue(str(pre_ratio))
945
+ self.cfg_fields["post_ratio"].SetValue(str(post_ratio))
946
+ self.cfg_fields["distance_match"].SetValue(str(distance_match))
947
+ self.cfg_fields["fuzzy_threshold"].SetValue(str(fuzzy_threshold))
948
+
949
+ except Exception as e:
950
+ # If validation fails, show error but don't block the user
951
+ wx.MessageBox(f"Validation Error: {str(e)}", "Error")
952
+
953
+ def on_path_change(self, evt):
954
+ path = evt.GetPath()
955
+ self.path_label.SetLabel(path)
956
+
957
+ def on_toggle(self, evt):
958
+ btn = evt.GetEventObject()
959
+ label = btn.GetLabel()
960
+ if label == "AND":
961
+ btn.SetLabel("OR")
962
+ else:
963
+ btn.SetLabel("AND")
964
+
965
+ def on_abort(self, evt):
966
+ """Abort button now properly stops all processes"""
967
+ if self.worker and self.worker.is_alive():
968
+ # Set the stop event to signal all running operations to abort
969
+ self.stop_event.set()
970
+
971
+ # Disable buttons immediately
972
+ self.abort_button.Disable()
973
+ self.start_button.Enable()
974
+
975
+ # Clear any text that might have been set during processing
976
+ wx.CallAfter(self.wildcard_box.SetValue, "Aborting...")
977
+ wx.CallAfter(self.fuzzy_box.SetValue, "Aborting...")
978
+
979
+ def on_start(self, evt):
980
+ # get path
981
+ path = self.path_label.GetLabel()
982
+ if not path or path == "No file/folder selected":
983
+ wx.MessageBox("Please select a file or folder first.", "Error")
984
+ return
985
+
986
+ try:
987
+ if os.path.isdir(path):
988
+ txts = [str(Path(path) / f) for f in sorted(os.listdir(path))
989
+ if f.lower().endswith(".txt") and os.path.isfile(os.path.join(path, f))]
990
+ if not txts:
991
+ wx.MessageBox("Selected folder contains no .txt files.", "Error")
992
+ return
993
+ paths = txts
994
+ else:
995
+ if not os.path.isfile(path):
996
+ wx.MessageBox("Selected path is not a file.", "Error")
997
+ return
998
+ # Only allow .txt files - this validation was missing before
999
+ if not path.lower().endswith(".txt"):
1000
+ wx.MessageBox("Please select a .txt file.", "Error")
1001
+ return
1002
+ paths = [path]
1003
+ except Exception as e:
1004
+ wx.MessageBox(f"Failed to access path: {str(e)}", "Error")
1005
+ return
1006
+
1007
+ # prepare config
1008
+ try:
1009
+ cfg = {
1010
+ "snippet_size": int(self.cfg_fields["snippet_size"].GetValue().strip()),
1011
+ "pre_ratio": float(self.cfg_fields["pre_ratio"].GetValue().strip()),
1012
+ "post_ratio": float(self.cfg_fields["post_ratio"].GetValue().strip()),
1013
+ "distance_match": int(self.cfg_fields["distance_match"].GetValue().strip()),
1014
+ "fuzzy_threshold": float(self.cfg_fields["fuzzy_threshold"].GetValue().strip()),
1015
+ }
1016
+ except Exception:
1017
+ wx.MessageBox("Please check numeric configuration values.", "Error")
1018
+ return
1019
+
1020
+ # buzzwords = [t.GetValue().strip() for t in self.buzz_inputs]
1021
+ # cfg["buzzwords"] = buzzwords
1022
+ # cfg["search_type"] = "AND" if self.toggle_buttons[0].GetLabel() == "AND" else "OR"
1023
+
1024
+
1025
+ buzzwords_list = [t.GetValue().strip() for t in self.buzz_inputs]
1026
+ search_type_value = "AND" if self.toggle_buttons[0].GetLabel() == "AND" else "OR"
1027
+
1028
+ cfg["buzzwords"] = buzzwords_list
1029
+ cfg["search_type"] = search_type_value
1030
+
1031
+
1032
+ # UI state
1033
+ self.start_button.Disable()
1034
+ self.abort_button.Enable()
1035
+ self.wildcard_box.SetValue("Running...")
1036
+ self.fuzzy_box.SetValue("Running...")
1037
+
1038
+ # reset stop_event and start thread
1039
+ self.stop_event.clear()
1040
+
1041
+ # Overwrite output files at the beginning of each new search
1042
+ Path("output_snippets.txt").write_text("", encoding="utf-8", errors="surrogateescape")
1043
+ Path("output_fuzzy_snippets.txt").write_text("", encoding="utf-8", errors="surrogateescape")
1044
+
1045
+ self.worker = SearchThread(paths, cfg, self.stop_event, self.on_search_complete)
1046
+ self.worker.start()
1047
+
1048
+ def on_search_complete(self, wildcard_text, fuzzy_text, finished_ok):
1049
+ # This callback runs in worker thread; must marshal to main GUI thread
1050
+ def _update():
1051
+ if finished_ok:
1052
+ self.wildcard_box.SetValue(wildcard_text)
1053
+ self.fuzzy_box.SetValue(fuzzy_text)
1054
+
1055
+ # Append results to output files for each processed file
1056
+ Path("output_snippets.txt").write_text(wildcard_text, encoding="utf-8", errors="surrogateescape")
1057
+ Path("output_fuzzy_snippets.txt").write_text(fuzzy_text, encoding="utf-8", errors="surrogateescape")
1058
+
1059
+ else:
1060
+ # signals either error or aborted
1061
+ self.wildcard_box.SetValue(wildcard_text or "Aborted / Error")
1062
+ self.fuzzy_box.SetValue(fuzzy_text or "Aborted / Error")
1063
+ self.stop_event.clear()
1064
+ self.start_button.Enable()
1065
+ self.abort_button.Disable()
1066
+
1067
+ wx.CallAfter(_update)
1068
+
1069
+ if __name__ == "__main__":
1070
+ app = wx.App(False)
1071
+ frame = MainFrame()
1072
+ frame.Show()
1073
+ app.MainLoop()
1074
+