From 7280347e3841494f7cfe3c9bde18121c7b8672ef Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 08:53:18 +0100 Subject: [PATCH 01/12] Update MainWindow.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In PyQt5, you could write Qt.AlignCenter directly, but in PyQt6 you must use the AlignmentFlag enum --- MainWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.py b/MainWindow.py index c96e6dc..5234140 100644 --- a/MainWindow.py +++ b/MainWindow.py @@ -37,7 +37,7 @@ def __init__(self, *args, **kwargs): \nBy default, notes in different groups are marked as duplicates when their fields with the same number matches.\ \nYou can disable this and specificy your own conditions for duplicate notes if you enable \'advanced mode\' below.\ \nHover over \'advanced mode\' or \'RegEx capture\' for more explanation.') - self.intro.setAlignment(Qt.AlignCenter) + self.intro.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.intro) #Create Comparer object and then using the Comparer, create subwindows. From 85b40fbfec059132feb9caa68fa008fdd82e973e Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 08:54:39 +0100 Subject: [PATCH 02/12] Update GroupWindow.py --- GroupWindow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GroupWindow.py b/GroupWindow.py index ed640c4..9b87957 100644 --- a/GroupWindow.py +++ b/GroupWindow.py @@ -38,7 +38,7 @@ def __init__(self, Comparer, parent, *args, **kwargs): #Create and add widgets to this layout group window self.groupNum = self.groupIndex + 1 self.title = QLabel(f"Group {self.groupNum} (G{self.groupNum})", parent) - self.title.setAlignment(Qt.AlignCenter) + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.addWidget(self.title) #Add widget to select group type and link trigger method @@ -282,4 +282,4 @@ def setEnabledAll(self, boolean): self.tagBox.setEnabled(boolean) self.fieldTableLabel.setEnabled(boolean) - self.fieldTable.setEnabledAll(boolean) \ No newline at end of file + self.fieldTable.setEnabledAll(boolean) From c6b4c1eb726b3f5df814c5ca8d210e7832100996 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:06:23 +0100 Subject: [PATCH 03/12] Update CustomQt.py --- CustomQt.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/CustomQt.py b/CustomQt.py index 78db3d1..478a735 100644 --- a/CustomQt.py +++ b/CustomQt.py @@ -8,7 +8,7 @@ from aqt.utils import showInfo #Import all of the Qt GUI library -from aqt.qt import * +from aqt.qt import QPalette, QBrush, QColor, QComboBox, QStyledItemDelegate, QEvent, QFontMetrics, QStandardItem, Qt # Qt classes from Anki's qt wrapper #Taken from: https://gis.stackexchange.com/questions/350148/qcombobox-multiple-selection-pyqt5 @@ -28,8 +28,8 @@ def __init__(self, *args, **kwargs): self.setEditable(True) self.lineEdit().setReadOnly(True) # Make the lineedit the same color as QPushButton - palette = qApp.palette() - palette.setBrush(QPalette.Base, palette.button()) + palette = mw.palette() + palette.setBrush(QPalette.ColorRole.Base, palette.button()) self.lineEdit().setPalette(palette) # Use custom delegate @@ -53,7 +53,7 @@ def resizeEvent(self, event): def eventFilter(self, object, event): if object == self.lineEdit(): - if event.type() == QEvent.MouseButtonRelease: + if event.type() == QEvent.Type.MouseButtonRelease: if self.closeOnLineEditClick: self.hidePopup() else: @@ -62,14 +62,14 @@ def eventFilter(self, object, event): return False if object == self.view().viewport(): - if event.type() == QEvent.MouseButtonRelease: + if event.type() == QEvent.Type.MouseButtonRelease: index = self.view().indexAt(event.pos()) item = self.model().item(index.row()) - if item.checkState() == Qt.Checked: - item.setCheckState(Qt.Unchecked) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) else: - item.setCheckState(Qt.Checked) + item.setCheckState(Qt.CheckState.Checked) return True return False @@ -93,13 +93,13 @@ def timerEvent(self, event): def updateText(self): texts = [] for i in range(self.model().rowCount()): - if self.model().item(i).checkState() == Qt.Checked: + if self.model().item(i).checkState() == Qt.CheckState.Checked: texts.append(self.model().item(i).text()) text = ", ".join(texts) # Compute elided text (with "...") metrics = QFontMetrics(self.lineEdit().font()) - elidedText = metrics.elidedText(text, Qt.ElideRight, self.lineEdit().width()) + elidedText = metrics.elidedText(text, Qt.TextElideMode.ElideRight, self.lineEdit().width()) self.lineEdit().setText(elidedText) def addItem(self, text, data=None): @@ -109,8 +109,8 @@ def addItem(self, text, data=None): item.setData(text) else: item.setData(data) - item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) - item.setData(Qt.Unchecked, Qt.CheckStateRole) + item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) + item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole) self.model().appendRow(item) def addItems(self, texts, datalist=None): From 0396a002cd43aae0b291d41922895294c7bb48de Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:11:48 +0100 Subject: [PATCH 04/12] Update GroupWindow.py --- GroupWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GroupWindow.py b/GroupWindow.py index 9b87957..cf6d84b 100644 --- a/GroupWindow.py +++ b/GroupWindow.py @@ -77,7 +77,7 @@ def __init__(self, Comparer, parent, *args, **kwargs): #Add widget with autocompleter to enter a tag for the duplicate action self.tagBox = QLineEdit(parent) completer = QCompleter(self.group.fieldInfo['Tags'].keys(), parent) - completer.setCaseSensitivity(Qt.CaseInsensitive) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self.tagBox.setCompleter(completer) self.tagBox.setPlaceholderText('Enter an existing tag or a new one.') self.addWidget(self.tagBox) From 81e64e0294d1cdbce57e1dc1368c3fde4660ebe1 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:13:07 +0100 Subject: [PATCH 05/12] Update FieldTable.py --- FieldTable.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FieldTable.py b/FieldTable.py index 8b0c909..144f83a 100644 --- a/FieldTable.py +++ b/FieldTable.py @@ -40,9 +40,9 @@ def __init__(self, group, *args, **kwargs): self.addFieldRow() #Resize the columns - self.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - self.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed) + self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) self.horizontalHeader().resizeSection(2, 65) #Method trigger to update the field in the Comparer object and also adds a new row if the index = row count - 1 From 59483c3bc1b97f9b391b9d59e04dae64749e65d1 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:15:24 +0100 Subject: [PATCH 06/12] Update QueueDialog.py --- QueueDialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QueueDialog.py b/QueueDialog.py index 0e6060d..f9dd8a7 100644 --- a/QueueDialog.py +++ b/QueueDialog.py @@ -12,7 +12,7 @@ from aqt.utils import showInfo #Import all of the Qt GUI library -from aqt.qt import * +from aqt.qt import QComboBox, QDialog, QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, Qt #Import local .py modules from . import Utils @@ -40,7 +40,7 @@ def __init__(self, Comparer, parent, *args, **kwargs): If you want to change the action for all duplicates in a group at once, close this window, select the appropiate action from the dropdown menu and reopen this window by pressing 'show duplicates'. If you hover over a duplicate note you will be able to see all of its fields. Some notes are marked as duplicates multiple times and all of the set actions will therefore be performed upon it.''', self) - self.intro.setAlignment(Qt.AlignCenter) + self.intro.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.intro) #Add a table widget to display the queue From e1874bf7e827f9da31047ff8f8dc892e98a98af8 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:20:03 +0100 Subject: [PATCH 07/12] Update QueueDialog.py --- QueueDialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/QueueDialog.py b/QueueDialog.py index f9dd8a7..7859037 100644 --- a/QueueDialog.py +++ b/QueueDialog.py @@ -208,15 +208,15 @@ def updateTextBox(self, rowIndex, groupIndex, textBox, action): #Method to ask for conformation for performing the actions by creating a message box def askConfirmation(self): msg = QMessageBox() - msg.setIcon(QMessageBox.Question) + msg.setIcon(QMessageBox.Icon.Question) msg.setText("All actions have been set and are ready to be performed.") msg.setInformativeText("Are you sure you want to perform the set actions on the notes?") msg.setWindowTitle("Confirmation") - msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) res = msg.exec() #When the user agrees, close the current window #and perform the actions - if res == QMessageBox.Ok: + if res == QMessageBox.StandardButton.Ok: self.accept() self.Comparer.performActions(self.maxRows) From d4ea2b1bb35cf143904b56b6ac706c8cd8ec036c Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 09:25:51 +0100 Subject: [PATCH 08/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 609c22b..c4adcce 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Note Comparer Anki -An anki addon for 2.1 for comparing notes for duplicates. +An anki addon for > 25.09 for comparing notes for duplicates. ## What to use Note Comparer for? ![Main window](/screenshots/main.jpg) From 2ef07c5ef62ab89c1b6238cd23b3dac31fa83808 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:20:19 +0100 Subject: [PATCH 09/12] Update QueueDialog.py --- QueueDialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/QueueDialog.py b/QueueDialog.py index 7859037..78d80a4 100644 --- a/QueueDialog.py +++ b/QueueDialog.py @@ -80,9 +80,9 @@ def __init__(self, Comparer, parent, *args, **kwargs): #Resize the columns for groupIndex in range(self.Comparer.groupNum): - self.queueTable.horizontalHeader().setSectionResizeMode(0 + groupIndex*3, QHeaderView.Stretch) - self.queueTable.horizontalHeader().setSectionResizeMode(1 + groupIndex*3, QHeaderView.Fixed) - self.queueTable.horizontalHeader().setSectionResizeMode(2 + groupIndex*3, QHeaderView.Fixed) + self.queueTable.horizontalHeader().setSectionResizeMode(0 + groupIndex*3, QHeaderView.ResizeMode.Stretch) + self.queueTable.horizontalHeader().setSectionResizeMode(1 + groupIndex*3, QHeaderView.ResizeMode.Fixed) + self.queueTable.horizontalHeader().setSectionResizeMode(2 + groupIndex*3, QHeaderView.ResizeMode.Fixed) self.queueTable.horizontalHeader().resizeSection(1 + groupIndex*3, 105) self.queueTable.horizontalHeader().resizeSection(2 + groupIndex*3, 105) From 7fa3a9ee204b464362c1eb45079dd150448826a2 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:45:54 +0100 Subject: [PATCH 10/12] Update Node.py --- Node.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/Node.py b/Node.py index e578166..ad6cebb 100644 --- a/Node.py +++ b/Node.py @@ -9,6 +9,8 @@ #Class to build a conditional tree to compare notes class Node: + _negation = False + def __init__(self, string, depth = 0, removeBrackets = True): self.children = [] if removeBrackets: @@ -23,17 +25,30 @@ def __init__(self, string, depth = 0, removeBrackets = True): self.rightValue = None self.solveMethod = None + # using property decorator as getter function for negation + @property + def negation(self): + return Node._negation + + # a setter function for negation + @negation.setter + def negation(self, neg): + if neg: + Node._negation = False + else: + Node._negation = True + #Function to set a string def setString(self, string): try: self.string = Utils.removeBrackets(string.replace('\n', ' ').replace('\t', ' ')) except re.error as e: raise e - + #Recursive tree method to create children by chopping the condition string set to 'value' into parts #to base new children on def createChildren(self): - #echo(self.string + ':' + str(self.depth)) + #echo(self.string + ': depth:' + str(self.depth)) #Remove any current children self.children = [] @@ -58,13 +73,13 @@ def createChildren(self): #Then, split the top level array based on 'and'/'or' operators #and clean up any empty values #these are the values for the children - childValues = [cv for cv in re.split(r'( and )|( or )', string) if cv not in [None, '']] + childValues = [cv for cv in re.split(r'( and )|( or )|(not )', string) if cv not in [None, '']] #If there are child values, rebuild every child value using the saved brackets, #create new children and also let them make their own if len(childValues) > 1: + i = 0 for cv in childValues: - #Rebuild the child value using the saved brackets while '$' in cv: for match in re.finditer(r'\$(\d+)', cv): @@ -72,6 +87,7 @@ def createChildren(self): #Remove any excess brackets from the child value, #create a new child, add it and activate it + #echo(string + ': depth:' + str(self.depth) + ' :cv:' + cv) child = Node(cv, self.depth + 1) self.children.append(child) child.createChildren() @@ -83,7 +99,7 @@ def createChildren(self): else: #When the end child node is an operator return - if string in ['and', 'or']: + if string in ['and', 'or', 'not']: return #Split the current value into left operand, operator and right operand @@ -91,7 +107,7 @@ def createChildren(self): #When the length isn't 3 raise an error if len(stringSplit) != 3: - raise re.error(f'"{string}" is not a valid condition.') + raise re.error(f'"{string}" is too short and not a valid condition. Childvalues: "{childValues}"') #Retrieve the types of the operands leftType, self.leftValue = self.__class__.operandType(stringSplit[0]) @@ -130,7 +146,7 @@ def createChildren(self): #The neither operands can be an regular expression if leftType == 'regex' or rightType == 'regex': - raise re.error(f"The neither the left or right part of \"{string}\" can be a regular expression.") + raise re.error(f"Neither the left or right part of \"{string}\" can be a regular expression.") #Save the solve method self.solveMethod = self.insideCompare @@ -170,7 +186,7 @@ def solve(self, notes): if numChildren == 0: #When the value is an operator, return it - if self.string in ['and', 'or']: + if self.string in ['and', 'or', 'not']: return self.string #When the value isn't an operator, solve and return the elemental condition @@ -182,13 +198,28 @@ def solve(self, notes): else: totalCondition = self.children[0].solve(notes) currentOperator = '' - for i in range(1, numChildren): + oldOperator = '' + i = 1 + while i < numChildren: + condString = '' + ''' + for j in range(1, numChildren): + newCondition = self.children[j].solve(notes) + condString = condString + 'j:' + str(j) + ':' + str(newCondition) + ':' + ''' newCondition = self.children[i].solve(notes) - #When a child is an operator save it temporarily - if newCondition in ['and', 'or']: + if newCondition in ['and', 'or', 'not']: + #When the next child is a 'not' operator save it temporarily + if newCondition == 'not': + #setter assignment via getter call + self.negation = self.negation + oldOperator = currentOperator currentOperator = newCondition + # (G1F1 = G2F1) and not ('sound:hello' in G1F2) + # (G1F1 = G2F1) and not ('sound' in G1F2) and ('sound' in G2F2) + #When it is a condition combine it with the total condition #depending on the current operator, and delete the current operator afterwards. #if there is no currentOperator, raise an error @@ -197,9 +228,21 @@ def solve(self, notes): totalCondition = totalCondition and newCondition elif currentOperator == 'or': totalCondition = totalCondition or newCondition + elif currentOperator == 'not': + if isinstance(newCondition, bool): + #setter assignment via getter call + self.negation = self.negation + if oldOperator == 'and': + totalCondition = totalCondition and newCondition + elif oldOperator == 'or': + totalCondition = totalCondition or newCondition + else: + raise re.error(f'The use of the \'not\' operator is incorrect. i: "{str(i)}" cOp: "{currentOperator}" :newCond: "{newCondition}" :nc: "{str(numChildren)}" :string: "{self.string}" :total: "{totalCondition}"') + oldOperator = '' else: - raise re.error('The use of \'and\'/\'or\' operators is incorrect.') + raise re.error(f'The use of \'and\'/\'or\' operators is incorrect. i: "{str(i)}" cOp: "{currentOperator}" :newCond: "{newCondition}" :nc: "{str(numChildren)}" :string: "{self.string}" :total: "{totalCondition}"') currentOperator = '' + i = i + 1 #Return the total condition return totalCondition @@ -246,7 +289,10 @@ def equalCompare(self, notes): if isinstance(left, bool) or isinstance(right, bool): return False else: - return left == right + if self.negation: + return not(left == right) + else: + return left == right def inCompare(self, notes): @@ -258,7 +304,10 @@ def inCompare(self, notes): if isinstance(left, bool) or isinstance(right, bool): return False else: - return left in right if ' ' in left else Utils.wordIn(left, right) + if self.negation: + return not(left in right if ' ' in left else Utils.wordIn(left, right)) + else: + return left in right if ' ' in left else Utils.wordIn(left, right) def insideCompare(self, notes): @@ -270,7 +319,12 @@ def insideCompare(self, notes): if isinstance(left, bool) or isinstance(right, bool): return False else: - return left in right + if self.negation: + #self.logger.debug(f'inside neg2: "{bNegation}"') + return not(left in right) + else: + #self.logger.debug(f'insComp: "{self.string}" :inside: "{left in right}"') + return left in right def equalRegexCompare(self, notes): @@ -281,7 +335,10 @@ def equalRegexCompare(self, notes): if isinstance(left, bool): return False else: - return re.fullmatch(self.rightValue, left) != None + if self.negation: + return not(re.fullmatch(self.rightValue, left) != None) + else: + return re.fullmatch(self.rightValue, left) != None def inRegexCompare(self, notes): @@ -292,4 +349,7 @@ def inRegexCompare(self, notes): if isinstance(right, bool): return False else: - return re.search(self.leftValue, right) != None + if self.negation: + return not(re.search(self.leftValue, right) != None) + else: + return re.search(self.leftValue, right) != None From ce8bf997894569f797a07ef51765f2c5241e8379 Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:47:44 +0100 Subject: [PATCH 11/12] Update MainWindow.py --- MainWindow.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/MainWindow.py b/MainWindow.py index 5234140..2b7db1e 100644 --- a/MainWindow.py +++ b/MainWindow.py @@ -91,6 +91,15 @@ def __init__(self, *args, **kwargs):
Example: '(G1F1 = G2F1 and G1F2 = G2F2) or (G1F3 = G2F3 and G1F4 = G2F4)' means that either fields 1 and 2 must match OR fields 3 and 4 in order for all of these conditions together to be seen as 'True' and the notes to be seen as duplicates. +
  • With adding negation it is possible to compare original and new notes for missing or wrong values. The operator is: + +
    Example 1: '(G1F1 = G2F1) and not (G1F2 = G2F2)' means that the first field of both groups must match AND that field 2 of group 1 and group 2 does NOT match. +
    Example 2: '(G1F1 = G2F1) and not ("" = G1F2) and ("" = G2F2)' means that the first field of both groups must match + AND that field 2 of group 1 IS NOT empty, but field 2 of group 2 IS empty. +
  • ''') self.regexCheckBox = QCheckBox('Enable RegEx capture for advanced mode', self) @@ -249,7 +258,7 @@ def reportCompareProgress(self, percentage, timeLeft, activity): self.progressBar.setValue(percentage) self.progressActivityLabel.setText(activity) if timeLeft != None: - self.timeLeftLabel.setText(f'Time left: {str(datetime.timedelta(seconds=timeLeft))}\nDuplicates found: {len(self.Comparer.queue)}') + self.timeLeftLabel.setText(f'Time left: {str(datetime.timedelta(seconds=timeLeft))}\nHits found: {len(self.Comparer.queue)}') #Method to enable / disable all of the GUI elements def setEnabledAll(self, boolean): From c5d7e43ddc78b42849f20facf2f03290c17e68af Mon Sep 17 00:00:00 2001 From: wh-ge <128788764+wh-ge@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:49:11 +0100 Subject: [PATCH 12/12] Update README.md --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c4adcce..17ccf9d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Note Comparer Anki -An anki addon for > 25.09 for comparing notes for duplicates. +An anki addon for 2.1 for comparing notes for duplicates. ## What to use Note Comparer for? ![Main window](/screenshots/main.jpg) @@ -52,6 +52,15 @@ To that end, you have to specify which (parts of) fields much match in order for
    Example: '(G1F1 = G2F1 and G1F2 = G2F2) or (G1F3 = G2F3 and G1F4 = G2F4)' means that either fields 1 and 2 must match OR fields 3 and 4 in order for all of these conditions together to be seen as 'True' and the notes to be seen as duplicates. +
  • With adding negation it is possible to compare original and new notes for missing or wrong values. The operator is: + +
    Example 1: '(G1F1 = G2F1) and not (G1F2 = G2F2)' means that the first field of both groups must match AND that field 2 of group 1 and group 2 does NOT match. +
    Example 2: '(G1F1 = G2F1) and not ("" = G1F2) and ("" = G2F2)' means that the first field of both groups must match + AND that field 2 of group 1 IS NOT empty, but field 2 of group 2 IS empty. +
  • ## Regular expression capture and conditions