diff --git a/docs/index.md b/docs/index.md index 39dde0f..87ab78f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -176,9 +176,35 @@ Lieer can be configured using `gmi set`. Use without any options to get a list o **`Replace slash with dot`** is used to replace the sub-label separator (`/`) with a dot (`.`). I think this is easier to work with. *Important*: See note below on [changing this setting after initial sync](#changing-ignored-tags-and-translation-after-initial-sync). **`Ignore tags (local)`** can be used to specify a list of tags which should not be synced from local to remote (e.g. [`new`](#usage)). In addition to the user-configured tags these tags are ignored: `'attachment', 'encrypted', 'signed', 'passed', 'replied', 'muted', 'mute', 'todo', 'Trash', 'voicemail'`. Some are special tags in notmuch and some are unsupported by GMail. See [Caveats](#caveats) below for more explanations. *Note:* This setting expects [_translated_ tags](#translation-between-labels-and-tags). - + *Important*: See note below on [changing this setting after initial sync](#changing-ignored-tags-and-translation-after-initial-sync). +**`Ignore tags regex (local)`** can be used to specify a list of regex patterns for tags which should not be synced from local to remote. This works in addition to the exact-match `Ignore tags (local)` setting. A tag is ignored if it matches ANY pattern (union/OR logic). + +Patterns use Python regex syntax with search matching (pattern can match anywhere in the tag). Patterns are case-sensitive. + +Example patterns: +- `^draft-.*` - Ignores tags starting with "draft-" (e.g., `draft-v1`, `draft-reply`) +- `.*-temp$` - Ignores tags ending with "-temp" (e.g., `work-temp`, `file-temp`) +- `work/.*` - Ignores tags containing "work/" (e.g., `work/project`, `work/email`) +- `^(temp|draft|wip)-` - Ignores tags starting with "temp-", "draft-", or "wip-" + +*Note:* This setting expects [_translated_ tags](#translation-between-labels-and-tags), same as `Ignore tags (local)`. + +*Important*: See note below on [changing this setting after initial sync](#changing-ignored-tags-and-translation-after-initial-sync). Invalid regex patterns will generate warnings but won't prevent the tool from running. + +Usage: +```bash +# Set regex patterns (comma-separated) +gmi set --ignore-tags-regex-local "^draft-.*,^temp-.*,work/.*" + +# Clear regex patterns +gmi set --ignore-tags-regex-local "" + +# View current settings +gmi set +``` + **`Ignore tags (remote)`** can be used to specify a list of tags (labels) which should not be synced from remote (GMail) to local. By default the [`CATEGORY_*` type](https://developers.google.com/gmail/api/guides/labels) labels which are mapped to the Personal/Promotions/etc tabs in the GMail interface are ignored. You can specify that no label should ignored by doing: `gmi set --ignore-tags-remote ""`. *Note:* This setting expects [_*un*translated_ tags](#translation-between-labels-and-tags). *Important*: See note below on [changing this setting after initial sync](#changing-ignored-tags-and-translation-after-initial-sync). @@ -202,7 +228,7 @@ Before changing either setting make sure you are fully synchronized. After chang When changing the opposite setting: `--ignore-tags-local`, do a full push (dry-run first): `gmi push -f --dry-run`. -The same goes for the options `--replace-slash-with-dot` and `--local-trash-tag`. I prefer to do `gmi pull -f --dry-run` after changing this option. This will overwrite the local tags with the remote labels. +The same goes for the options `--ignore-tags-regex-local`, `--replace-slash-with-dot` and `--local-trash-tag`. I prefer to do `gmi pull -f --dry-run` after changing this option. This will overwrite the local tags with the remote labels. # Translation between labels and tags diff --git a/lieer/gmailieer.py b/lieer/gmailieer.py index 8430cfb..742615f 100755 --- a/lieer/gmailieer.py +++ b/lieer/gmailieer.py @@ -367,6 +367,13 @@ def main(self): help="Set custom tags to ignore when syncing from local to remote (comma-separated, after translations). Important: see the manual.", ) + parser_set.add_argument( + "--ignore-tags-regex-local", + type=str, + default=None, + help="Set regex patterns for tags to ignore when syncing from local to remote (comma-separated, case-sensitive, uses Python regex with search matching). A tag is ignored if it matches ANY pattern. Important: see the manual.", + ) + parser_set.add_argument( "--ignore-tags-remote", type=str, @@ -1134,6 +1141,9 @@ def set(self, args): if args.ignore_tags_local is not None: self.local.config.set_ignore_tags(args.ignore_tags_local) + if args.ignore_tags_regex_local is not None: + self.local.config.set_ignore_tags_regex(args.ignore_tags_regex_local) + if args.ignore_tags_remote is not None: self.local.config.set_ignore_remote_labels(args.ignore_tags_remote) @@ -1159,6 +1169,7 @@ def set(self, args): print("Ignore empty history ......:", self.local.config.ignore_empty_history) print("Replace . with / ..........:", self.local.config.replace_slash_with_dot) print("Ignore tags (local) .......:", self.local.config.ignore_tags) + print("Ignore tags regex (local) .:", self.local.config.ignore_tags_regex) print("Ignore labels (remote) ....:", self.local.config.ignore_remote_labels) print("Trash tag (local) .........:", self.local.config.local_trash_tag) print( diff --git a/lieer/local.py b/lieer/local.py index a9e5f79..27743dc 100644 --- a/lieer/local.py +++ b/lieer/local.py @@ -19,7 +19,9 @@ import fcntl import json import os +import re import shutil +import sys import tempfile from pathlib import Path @@ -113,6 +115,27 @@ def update_translation_list_with_overlay(self, translation_list_overlay): self.translate_labels[remote] = local self.labels_translate[local] = remote + def matches_ignore_regex(self, tag): + """ + Check if a tag matches any of the ignore regex patterns. + + Args: + tag: Tag string to check + + Returns: + bool: True if tag matches any regex pattern + """ + for regex in self.config._compiled_ignore_regex: + try: + if regex.search(tag): + return True + except Exception as e: + print( + f"Warning: Error matching regex pattern {regex.pattern}: {e}", + file=sys.stderr, + ) + return False + class RepositoryException(Exception): pass @@ -123,11 +146,40 @@ class Config: drop_non_existing_label = False ignore_empty_history = False ignore_tags = None + ignore_tags_regex = None ignore_remote_labels = None remove_local_messages = True file_extension = None local_trash_tag = "trash" translation_list_overlay = None + _compiled_ignore_regex = None + + def _compile_regex_patterns(self, patterns): + """ + Compile regex patterns and return valid patterns and compiled objects. + Invalid patterns generate warnings but are skipped. + + Args: + patterns: Iterable of regex pattern strings + + Returns: + Tuple of (valid_patterns, compiled_patterns) + - valid_patterns: List of valid pattern strings + - compiled_patterns: Tuple of compiled regex objects + """ + valid_patterns = [] + compiled_patterns = [] + for pattern in patterns: + try: + compiled = re.compile(pattern) + valid_patterns.append(pattern) + compiled_patterns.append(compiled) + except re.error as e: + print( + f"Warning: Invalid regex pattern '{pattern}': {e}", + file=sys.stderr, + ) + return valid_patterns, tuple(compiled_patterns) def __init__(self, config_f): self.config_f = config_f @@ -151,6 +203,15 @@ def __init__(self, config_f): self.ignore_empty_history = self.json.get("ignore_empty_history", False) self.remove_local_messages = self.json.get("remove_local_messages", True) self.ignore_tags = set(self.json.get("ignore_tags", [])) + + # Load regex patterns from config and compile them + regex_patterns = self.json.get("ignore_tags_regex", []) + valid_patterns, compiled_patterns = self._compile_regex_patterns( + regex_patterns + ) + self.ignore_tags_regex = set(valid_patterns) + self._compiled_ignore_regex = compiled_patterns + self.ignore_remote_labels = set( self.json.get("ignore_remote_labels", Remote.DEFAULT_IGNORE_LABELS) ) @@ -169,6 +230,7 @@ def write(self): self.json["drop_non_existing_label"] = self.drop_non_existing_label self.json["ignore_empty_history"] = self.ignore_empty_history self.json["ignore_tags"] = list(self.ignore_tags) + self.json["ignore_tags_regex"] = list(self.ignore_tags_regex) self.json["ignore_remote_labels"] = list(self.ignore_remote_labels) self.json["remove_local_messages"] = self.remove_local_messages self.json["file_extension"] = self.file_extension @@ -216,6 +278,27 @@ def set_ignore_tags(self, t): self.write() + def set_ignore_tags_regex(self, t): + """ + Set regex patterns for ignoring local tags. + + Args: + t: Comma-separated string of regex patterns + """ + if len(t.strip()) == 0: + self.ignore_tags_regex = set() + self._compiled_ignore_regex = () + else: + patterns = [p.strip() for p in t.split(",")] + # Compile patterns and collect only the valid ones + valid_patterns, compiled_patterns = self._compile_regex_patterns( + patterns + ) + self.ignore_tags_regex = set(valid_patterns) + self._compiled_ignore_regex = compiled_patterns + + self.write() + def set_ignore_remote_labels(self, t): if len(t.strip()) == 0: self.ignore_remote_labels = set() @@ -714,8 +797,12 @@ def update_tags(self, m, fname, db): else: # message is already in db, set local tags to match remote tags otags = nmsg.tags + # Collect all ignored tags (exact match + regex match) igntags = otags & self.ignore_labels - otags = otags - self.ignore_labels # remove ignored tags while checking + regex_ignored = {tag for tag in otags if self.matches_ignore_regex(tag)} + igntags = igntags | regex_ignored + # Remove all ignored tags for comparison + otags = otags - igntags if otags != set(labels): labels.extend(igntags) # add back local ignored tags before adding if not self.dry_run: diff --git a/lieer/remote.py b/lieer/remote.py index 57ee057..2ed7c93 100644 --- a/lieer/remote.py +++ b/lieer/remote.py @@ -651,6 +651,10 @@ def update(self, gmsg, nmsg, last_hist, force): # remove special notmuch tags tags = tags - self.gmailieer.local.ignore_labels + # remove tags matching regex patterns + tags = { + tag for tag in tags if not self.gmailieer.local.matches_ignore_regex(tag) + } add = list((tags - labels) - self.read_only_tags) rem = list((labels - tags) - self.read_only_tags) diff --git a/tests/test_ignore_tags_regex.py b/tests/test_ignore_tags_regex.py new file mode 100644 index 0000000..320e6de --- /dev/null +++ b/tests/test_ignore_tags_regex.py @@ -0,0 +1,139 @@ +""" +Tests for ignore_tags_regex_local functionality +""" + +import json +import os +import tempfile +from types import MethodType + +import pytest + +from lieer.local import Local + + +@pytest.fixture +def temp_config_file(): + """Create a temporary config file for testing""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = os.path.join(tmpdir, ".gmailieer.json") + yield config_file + + +@pytest.fixture +def fake_local(temp_config_file): + """Create a fake Local instance for testing regex matching""" + + class FakeLocal: + pass + + def create_with_patterns(patterns): + test_config = {"account": "test@example.com", "ignore_tags_regex": patterns} + with open(temp_config_file, "w") as f: + json.dump(test_config, f) + + fake = FakeLocal() + fake.config = Local.Config(temp_config_file) + fake.matches_ignore_regex = MethodType(Local.matches_ignore_regex, fake) + return fake + + return create_with_patterns + + +def test_regex_patterns_load_and_compile(temp_config_file): + """Test that regex patterns load from config and compile correctly""" + test_config = { + "account": "test@example.com", + "ignore_tags_regex": ["^draft-.*", "temp-.*", "work/.*"], + } + + with open(temp_config_file, "w") as f: + json.dump(test_config, f) + + config = Local.Config(temp_config_file) + + assert len(config.ignore_tags_regex) == 3 + assert len(config._compiled_ignore_regex) == 3 + + +def test_invalid_regex_patterns_skipped(temp_config_file): + """Test that invalid regex patterns generate warnings but don't fail""" + test_config = { + "account": "test@example.com", + "ignore_tags_regex": ["^draft-.*", "[invalid(", "valid-pattern"], + } + + with open(temp_config_file, "w") as f: + json.dump(test_config, f) + + config = Local.Config(temp_config_file) + + # Only valid patterns should be compiled + assert len(config._compiled_ignore_regex) == 2 + assert "^draft-.*" in config.ignore_tags_regex + assert "valid-pattern" in config.ignore_tags_regex + assert "[invalid(" not in config.ignore_tags_regex + + +def test_regex_matching_patterns(fake_local): + """Test matching various regex patterns (prefix, suffix, contains)""" + local = fake_local(["^draft-.*", ".*-temp$", "work/.*"]) + + # Prefix match + assert local.matches_ignore_regex("draft-v1") + assert not local.matches_ignore_regex("my-draft") + + # Suffix match + assert local.matches_ignore_regex("file-temp") + assert not local.matches_ignore_regex("temp-file") + + # Contains match + assert local.matches_ignore_regex("work/project") + assert not local.matches_ignore_regex("homework") + + # No match + assert not local.matches_ignore_regex("normal-tag") + + +def test_regex_matching_case_sensitive(fake_local): + """Test that regex matching is case-sensitive""" + local = fake_local(["^draft-.*"]) + + assert local.matches_ignore_regex("draft-v1") + assert not local.matches_ignore_regex("Draft-v1") + + +def test_setter_method_persistence(temp_config_file): + """Test that setter method works and persists to config""" + test_config = {"account": "test@example.com"} + + with open(temp_config_file, "w") as f: + json.dump(test_config, f) + + config = Local.Config(temp_config_file) + config.set_ignore_tags_regex("^draft-.*,temp-.*") + + assert len(config.ignore_tags_regex) == 2 + assert len(config._compiled_ignore_regex) == 2 + + # Reload to verify persistence + config2 = Local.Config(temp_config_file) + assert len(config2.ignore_tags_regex) == 2 + assert "^draft-.*" in config2.ignore_tags_regex + + +def test_setter_method_clears_patterns(temp_config_file): + """Test that empty string clears patterns""" + test_config = { + "account": "test@example.com", + "ignore_tags_regex": ["^draft-.*"], + } + + with open(temp_config_file, "w") as f: + json.dump(test_config, f) + + config = Local.Config(temp_config_file) + config.set_ignore_tags_regex("") + + assert len(config.ignore_tags_regex) == 0 + assert len(config._compiled_ignore_regex) == 0