From 7383a67f94629d051067a0b452d599f89a96ae75 Mon Sep 17 00:00:00 2001 From: digitalsignalperson Date: Fri, 9 Jun 2023 01:50:14 -0700 Subject: [PATCH] Allow for tags with whitespaces and special characters --- README.md | 2 +- dodo/app.py | 4 ++-- dodo/keymap.py | 2 +- dodo/search.py | 26 ++++++++++++++------------ dodo/tag.py | 6 +++--- dodo/thread.py | 23 +++++++++++++---------- dodo/util.py | 21 +++++++++++++++++++++ 7 files changed, 55 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b2fb11c..cff6754 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ def snooze(days, mode='tag'): import datetime d = datetime.date.today() + datetime.timedelta(days=days) def f(search): - search.tag_thread(f'-inbox -unread +zzz-{d}', mode) + search.tag_thread(tags_remove=['inbox', 'unread'], tags_add=[f'zzz-{d}'], mode=mode) return f dodo.keymap.search_keymap['z z'] = ("snooze for 1 day", snooze(days=1)) diff --git a/dodo/app.py b/dodo/app.py index b02fdf8..4c24f38 100644 --- a/dodo/app.py +++ b/dodo/app.py @@ -230,8 +230,8 @@ def tag_bar(self, mode: Literal['tag', 'tag marked']='tag') -> None: def callback(tag_expr: str) -> None: w = self.tabs.currentWidget() if w and isinstance(w, panel.Panel): - if isinstance(w, search.SearchPanel): w.tag_thread(tag_expr, mode) - elif isinstance(w, thread.ThreadPanel): w.tag_message(tag_expr) + if isinstance(w, search.SearchPanel): w.tag_thread(tag_expr=tag_expr, mode=mode) + elif isinstance(w, thread.ThreadPanel): w.tag_message(tag_expr=tag_expr) w.refresh() self.command_bar.open(mode, callback) diff --git a/dodo/keymap.py b/dodo/keymap.py index fd15f19..d39d589 100644 --- a/dodo/keymap.py +++ b/dodo/keymap.py @@ -52,7 +52,7 @@ 'C-d': ('down 20', lambda p: [p.next_thread() for i in range(20)]), 'C-u': ('up 20', lambda p: [p.previous_thread() for i in range(20)]), '': ('open thread', lambda p: p.open_current_thread()), - 'a': ('tag -inbox -unread', lambda p: p.tag_thread('-inbox -unread')), + 'a': ('tag -inbox -unread', lambda p: p.tag_thread(tags_remove=['inbox', 'unread'])), 'u': ('toggle unread', lambda p: p.toggle_thread_tag('unread')), 'f': ('toggle flagged', lambda p: p.toggle_thread_tag('flagged')), '': ('toggle marked', lambda p: [p.toggle_thread_tag('marked'), p.next_thread()]), diff --git a/dodo/search.py b/dodo/search.py index 5762b02..12cdddc 100644 --- a/dodo/search.py +++ b/dodo/search.py @@ -27,6 +27,7 @@ from . import app from . import settings +from . import util from . import keymap from . import thread from . import panel @@ -94,7 +95,7 @@ def data(self, index: QModelIndex, role: int=Qt.ItemDataRole.DisplayRole) -> Any tag_icons = [] for t in thread_d['tags']: # don't bother showing TAG if it is in settings.hide_tags or the query is specifically 'tag:TAG' - if t not in settings.hide_tags and self.q != 'tag:' + t: + if t not in settings.hide_tags and self.q != f'tag:"{t}"': tag_icons.append(settings.tag_icons[t] if t in settings.tag_icons else f'[{t}]') return ' '.join(tag_icons) elif role == Qt.ItemDataRole.FontRole: @@ -257,27 +258,28 @@ def toggle_thread_tag(self, tag: str) -> None: thread = self.model.thread_json(self.tree.currentIndex()) if thread: if tag in thread['tags']: - tag_expr = '-' + tag + self.tag_thread(tags_remove=[tag]) else: - tag_expr = '+' + tag - self.tag_thread(tag_expr) + self.tag_thread(tags_add=[tag]) - - def tag_thread(self, tag_expr: str, mode: Literal['tag', 'tag marked']='tag') -> None: - """Apply the given tag expression to the selected thread + def tag_thread(self, tag_expr: str=None, tags_add: list=None, tags_remove: list=None, mode: Literal['tag', 'tag marked']='tag') -> None: + """Apply the given tag expression or lists of tags to add or remove to the selected thread A tag expression is a string consisting of one more statements of the form "+TAG" - or "-TAG" to add or remove TAG, respectively, separated by whitespace.""" + or "-TAG" to add or remove TAG, respectively, separated by whitespace. + + Tags containing spaces or special characters must be double quoted within the tag expression. + Alternatively, use the lists tags_add and tags_remove with the unquoted values. + Programatic uses of tags should always use the tags_add and tags_remove lists.""" - if not ('+' in tag_expr or '-' in tag_expr): - tag_expr = '+' + tag_expr + tag_args = util.format_tag_args(tag_expr, tags_add, tags_remove) if mode == 'tag': thread_id = self.model.thread_id(self.tree.currentIndex()) if thread_id: - subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['--', 'thread:' + thread_id]) + subprocess.run(['notmuch', 'tag'] + tag_args + ['--', 'thread:' + thread_id]) elif mode == 'tag marked': - subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['-marked','--', f'tag:marked AND ({self.q})']) + subprocess.run(['notmuch', 'tag'] + tag_args + ['-marked','--', f'tag:marked AND ({self.q})']) self.app.refresh_panels() diff --git a/dodo/tag.py b/dodo/tag.py index d1d01d7..0e93a50 100644 --- a/dodo/tag.py +++ b/dodo/tag.py @@ -50,10 +50,10 @@ def refresh(self) -> None: self.d: List[Tuple[str,str,str]] = [] for t in tag_str.splitlines(): - r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', 'tag:'+t], + r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', f'tag:"{t}"'], stdout=subprocess.PIPE) c = r1.stdout.decode('utf-8').strip() - r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', f'tag:{t} AND tag:unread'], + r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', f'tag:"{t}" AND tag:unread'], stdout=subprocess.PIPE) cu = r1.stdout.decode('utf-8').strip() self.d.append((t, cu, c)) @@ -208,7 +208,7 @@ def search_current_tag(self) -> None: tag = self.model.tag(self.tree.currentIndex()) if tag: - self.app.open_search('tag:' + tag) + self.app.open_search(f'tag:"{tag}"') diff --git a/dodo/thread.py b/dodo/thread.py index f7d2be9..85bc9b9 100644 --- a/dodo/thread.py +++ b/dodo/thread.py @@ -31,6 +31,7 @@ import email import email.message import tempfile +import shlex from . import app from . import settings @@ -420,7 +421,7 @@ def show_message(self, i: int=-1) -> None: m = self.model.message_at(self.current_message) if 'unread' in m['tags']: # this might change the filename, so we should refresh the model - self.tag_message('-unread') + self.tag_message(tags_remove=['unread']) self.refresh() m = self.model.message_at(self.current_message) @@ -476,22 +477,24 @@ def toggle_message_tag(self, tag: str) -> None: m = self.model.message_at(self.current_message) if m: if tag in m['tags']: - tag_expr = '-' + tag + self.tag_message(tags_remove=[tag]) else: - tag_expr = '+' + tag - self.tag_message(tag_expr) + self.tag_message(tags_add=[tag]) - def tag_message(self, tag_expr: str) -> None: - """Apply the given tag expression to the current message + def tag_message(self, tag_expr: str=None, tags_add: list=None, tags_remove: list=None) -> None: + """Apply the given tag expression or lists of tags to add or remove to the selected thread A tag expression is a string consisting of one more statements of the form "+TAG" - or "-TAG" to add or remove TAG, respectively, separated by whitespace.""" + or "-TAG" to add or remove TAG, respectively, separated by whitespace. + + Tags containing spaces or special characters must be double quoted within the tag expression. + Alternatively, use the lists tags_add and tags_remove with the unquoted values. + Programatic uses of tags should always use the tags_add and tags_remove lists.""" m = self.model.message_at(self.current_message) if m: - if not ('+' in tag_expr or '-' in tag_expr): - tag_expr = '+' + tag_expr - r = subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['--', 'id:' + m['id']], + tag_args = util.format_tag_args(tag_expr, tags_add, tags_remove) + r = subprocess.run(['notmuch', 'tag'] + tag_args + ['--', 'id:' + m['id']], stdout=subprocess.PIPE) self.app.refresh_panels() diff --git a/dodo/util.py b/dodo/util.py index f28852c..c47e684 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -515,3 +515,24 @@ def key_string(e: QKeyEvent) -> str: # print(cmd) return cmd + +def format_tag_args(tag_expr: str=None, tags_add: list=None, tags_remove: list=None) -> list: + tag_args = [] + if tag_expr: + # This is something typed by the user in the search or thread panel + # assume that they use whitepace and quotes correctly + for tag in shlex.split(tag_expr): + if tag[0] not in ['+', '-']: + tag = '+' + tag + if ' ' in tag: + # For example: shlex.split('+asdf +"asdf asdf"') => ['+asdf', '+asdf asdf'] + # shelx.split removed quotes, so add them back + tag = tag[0] + f'"{tag[1:]}"' + tag_args.append(tag) + if tags_add: + for tag in tags_add: + tag_args.append(f'+"{tag}"') + if tags_remove: + for tag in tags_remove: + tag_args.append(f'-"{tag}"') + return tag_args