Skip to content

Commit 8d541f3

Browse files
authored
Merge pull request #191 from yggdrasil75/addregex
Add option to use regex for find and replace
2 parents 1386d3f + ae25941 commit 8d541f3

File tree

2 files changed

+91
-36
lines changed

2 files changed

+91
-36
lines changed

taggui/dialogs/find_and_replace_dialog.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from PySide6.QtCore import Qt, Slot
24
from PySide6.QtWidgets import (QDialog, QGridLayout, QLabel, QPushButton,
35
QVBoxLayout)
@@ -33,13 +35,15 @@ def __init__(self, parent, image_list_model: ImageListModel):
3335
Qt.AlignmentFlag.AlignRight)
3436
grid_layout.addWidget(QLabel('Whole tags only'), 3, 0,
3537
Qt.AlignmentFlag.AlignRight)
36-
self.find_line_edit = SettingsLineEdit(key='find_text')
37-
self.find_line_edit.setClearButtonEnabled(True)
38-
self.find_line_edit.textChanged.connect(self.display_match_count)
39-
grid_layout.addWidget(self.find_line_edit, 0, 1)
40-
self.replace_line_edit = SettingsLineEdit(key='replace_text')
41-
self.replace_line_edit.setClearButtonEnabled(True)
42-
grid_layout.addWidget(self.replace_line_edit, 1, 1)
38+
grid_layout.addWidget(QLabel('Use regex for find text'), 4, 0,
39+
Qt.AlignmentFlag.AlignRight)
40+
self.find_text_line_edit = SettingsLineEdit(key='find_text')
41+
self.find_text_line_edit.setClearButtonEnabled(True)
42+
self.find_text_line_edit.textChanged.connect(self.display_match_count)
43+
grid_layout.addWidget(self.find_text_line_edit, 0, 1)
44+
self.replace_text_line_edit = SettingsLineEdit(key='replace_text')
45+
self.replace_text_line_edit.setClearButtonEnabled(True)
46+
grid_layout.addWidget(self.replace_text_line_edit, 1, 1)
4347
self.scope_combo_box = SettingsComboBox(key='replace_scope')
4448
self.scope_combo_box.addItems(list(Scope))
4549
self.scope_combo_box.currentTextChanged.connect(
@@ -50,40 +54,54 @@ def __init__(self, parent, image_list_model: ImageListModel):
5054
self.whole_tags_only_check_box.stateChanged.connect(
5155
self.display_match_count)
5256
grid_layout.addWidget(self.whole_tags_only_check_box, 3, 1)
57+
self.use_regex_check_box = SettingsBigCheckBox(key='replace_use_regex',
58+
default=False)
59+
self.use_regex_check_box.stateChanged.connect(self.display_match_count)
60+
grid_layout.addWidget(self.use_regex_check_box, 4, 1)
5361
layout.addLayout(grid_layout)
5462
self.replace_button = QPushButton('Replace')
5563
self.replace_button.clicked.connect(self.replace)
5664
self.replace_button.clicked.connect(self.display_match_count)
5765
layout.addWidget(self.replace_button)
5866
self.display_match_count()
5967

68+
def disable_replace_button(self):
69+
self.replace_button.setText('Replace')
70+
self.replace_button.setEnabled(False)
71+
6072
@Slot()
6173
def display_match_count(self):
62-
text = self.find_line_edit.text()
74+
text = self.find_text_line_edit.text()
6375
if not text:
64-
self.replace_button.setText('Replace')
65-
self.replace_button.setEnabled(False)
76+
self.disable_replace_button()
6677
return
6778
self.replace_button.setEnabled(True)
6879
scope = self.scope_combo_box.currentText()
6980
whole_tags_only = self.whole_tags_only_check_box.isChecked()
70-
match_count = self.image_list_model.get_text_match_count(
71-
text, scope, whole_tags_only)
81+
use_regex = self.use_regex_check_box.isChecked()
82+
try:
83+
match_count = self.image_list_model.get_text_match_count(
84+
text, scope, whole_tags_only, use_regex)
85+
except re.error:
86+
self.disable_replace_button()
87+
return
7288
self.replace_button.setText(f'Replace {match_count} '
7389
f'{pluralize("instance", match_count)}')
7490

7591
@Slot()
7692
def replace(self):
7793
scope = self.scope_combo_box.currentText()
94+
use_regex = self.use_regex_check_box.isChecked()
7895
if self.whole_tags_only_check_box.isChecked():
79-
replace_text = self.replace_line_edit.text()
96+
replace_text = self.replace_text_line_edit.text()
8097
if replace_text:
81-
self.image_list_model.rename_tags([self.find_line_edit.text()],
82-
replace_text, scope)
98+
self.image_list_model.rename_tags(
99+
[self.find_text_line_edit.text()], replace_text, scope,
100+
use_regex)
83101
else:
84-
self.image_list_model.delete_tags([self.find_line_edit.text()],
85-
scope)
102+
self.image_list_model.delete_tags(
103+
[self.find_text_line_edit.text()], scope, use_regex)
86104
else:
87105
self.image_list_model.find_and_replace(
88-
self.find_line_edit.text(), self.replace_line_edit.text(),
89-
scope)
106+
self.find_text_line_edit.text(),
107+
self.replace_text_line_edit.text(), scope, use_regex)

taggui/models/image_list_model.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import random
2+
import re
23
import sys
34
from collections import Counter, deque
45
from dataclasses import dataclass
@@ -241,21 +242,31 @@ def is_image_in_scope(self, scope: Scope | str, image_index: int,
241242
return self.image_list_selection_model.isSelected(proxy_index)
242243

243244
def get_text_match_count(self, text: str, scope: Scope | str,
244-
whole_tags_only: bool) -> int:
245+
whole_tags_only: bool, use_regex: bool) -> int:
245246
"""Get the number of instances of a text in all captions."""
246247
match_count = 0
247248
for image_index, image in enumerate(self.images):
248249
if not self.is_image_in_scope(scope, image_index, image):
249250
continue
250251
if whole_tags_only:
251-
match_count += image.tags.count(text)
252+
if use_regex:
253+
match_count += len([
254+
tag for tag in image.tags
255+
if re.fullmatch(pattern=text, string=tag)
256+
])
257+
else:
258+
match_count += image.tags.count(text)
252259
else:
253260
caption = self.tag_separator.join(image.tags)
254-
match_count += caption.count(text)
261+
if use_regex:
262+
match_count += len(re.findall(pattern=text,
263+
string=caption))
264+
else:
265+
match_count += caption.count(text)
255266
return match_count
256267

257268
def find_and_replace(self, find_text: str, replace_text: str,
258-
scope: Scope | str):
269+
scope: Scope | str, use_regex: bool):
259270
"""
260271
Find and replace arbitrary text in captions, within and across tag
261272
boundaries.
@@ -269,10 +280,16 @@ def find_and_replace(self, find_text: str, replace_text: str,
269280
if not self.is_image_in_scope(scope, image_index, image):
270281
continue
271282
caption = self.tag_separator.join(image.tags)
272-
if find_text not in caption:
273-
continue
283+
if use_regex:
284+
if not re.search(pattern=find_text, string=caption):
285+
continue
286+
caption = re.sub(pattern=find_text, repl=replace_text,
287+
string=caption)
288+
else:
289+
if find_text not in caption:
290+
continue
291+
caption = caption.replace(find_text, replace_text)
274292
changed_image_indices.append(image_index)
275-
caption = caption.replace(find_text, replace_text)
276293
image.tags = caption.split(self.tag_separator)
277294
self.write_image_tags_to_disk(image)
278295
if changed_image_indices:
@@ -465,39 +482,59 @@ def add_tags(self, tags: list[str], image_indices: list[QModelIndex]):
465482

466483
@Slot(list, str)
467484
def rename_tags(self, old_tags: list[str], new_tag: str,
468-
scope: Scope | str = Scope.ALL_IMAGES):
485+
scope: Scope | str = Scope.ALL_IMAGES,
486+
use_regex: bool = False):
469487
self.add_to_undo_stack(
470488
action_name=f'Rename {pluralize("Tag", len(old_tags))}',
471489
should_ask_for_confirmation=True)
472490
changed_image_indices = []
473491
for image_index, image in enumerate(self.images):
474492
if not self.is_image_in_scope(scope, image_index, image):
475493
continue
476-
if not any(old_tag in image.tags for old_tag in old_tags):
477-
continue
494+
if use_regex:
495+
pattern = old_tags[0]
496+
if not any(re.fullmatch(pattern=pattern, string=image_tag)
497+
for image_tag in image.tags):
498+
continue
499+
image.tags = [new_tag if re.fullmatch(pattern=pattern,
500+
string=image_tag)
501+
else image_tag for image_tag in image.tags]
502+
else:
503+
if not any(old_tag in image.tags for old_tag in old_tags):
504+
continue
505+
image.tags = [new_tag if image_tag in old_tags else image_tag
506+
for image_tag in image.tags]
478507
changed_image_indices.append(image_index)
479-
image.tags = [new_tag if image_tag in old_tags else image_tag
480-
for image_tag in image.tags]
481508
self.write_image_tags_to_disk(image)
482509
if changed_image_indices:
483510
self.dataChanged.emit(self.index(changed_image_indices[0]),
484511
self.index(changed_image_indices[-1]))
485512

486513
@Slot(list)
487514
def delete_tags(self, tags: list[str],
488-
scope: Scope | str = Scope.ALL_IMAGES):
515+
scope: Scope | str = Scope.ALL_IMAGES,
516+
use_regex: bool = False):
489517
self.add_to_undo_stack(
490518
action_name=f'Delete {pluralize("Tag", len(tags))}',
491519
should_ask_for_confirmation=True)
492520
changed_image_indices = []
493521
for image_index, image in enumerate(self.images):
494522
if not self.is_image_in_scope(scope, image_index, image):
495523
continue
496-
if not any(tag in image.tags for tag in tags):
497-
continue
524+
if use_regex:
525+
pattern = tags[0]
526+
if not any(re.fullmatch(pattern=pattern, string=image_tag)
527+
for image_tag in image.tags):
528+
continue
529+
image.tags = [image_tag for image_tag in image.tags
530+
if not re.fullmatch(pattern=pattern,
531+
string=image_tag)]
532+
else:
533+
if not any(tag in image.tags for tag in tags):
534+
continue
535+
image.tags = [image_tag for image_tag in image.tags
536+
if image_tag not in tags]
498537
changed_image_indices.append(image_index)
499-
image.tags = [image_tag for image_tag in image.tags
500-
if image_tag not in tags]
501538
self.write_image_tags_to_disk(image)
502539
if changed_image_indices:
503540
self.dataChanged.emit(self.index(changed_image_indices[0]),

0 commit comments

Comments
 (0)