Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lieer/gmailieer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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(
Expand Down
89 changes: 88 additions & 1 deletion lieer/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import fcntl
import json
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions lieer/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
139 changes: 139 additions & 0 deletions tests/test_ignore_tags_regex.py
Original file line number Diff line number Diff line change
@@ -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
Loading