Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 13 additions & 2 deletions dodo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ def open_tags(self, keep_open: bool=False) -> None:

p = tag.TagPanel(self, keep_open)
self.add_panel(p)

def open_tags_narrowed(self, filter_tags) -> None:
p = tag.TagPanel(self, filter_tags=filter_tags)
self.add_panel(p)

def search_bar(self) -> None:
"""Open command bar for searching"""
Expand All @@ -230,8 +234,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)

Expand Down Expand Up @@ -279,6 +283,13 @@ def refresh_panels(self) -> None:
w = self.tabs.currentWidget()
if w and isinstance(w, panel.Panel): w.refresh()

def refresh_title(self) -> None:
"""Refresh the title of the current panel"""
i = self.tabs.currentIndex()
w = self.tabs.widget(i)
if isinstance(w, panel.Panel):
self.tabs.setTabText(i, w.title())

def prompt_quit(self) -> None:
"""A 'soft' quit function, which gives each open tab the opportunity to prompt
the user and possible cancel closing."""
Expand Down
1 change: 1 addition & 0 deletions dodo/helpwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def __init__(self, parent: Optional[QWidget]=None):
maps = [
("Global", keymap.global_keymap),
("Search view", keymap.search_keymap),
("Tag view", keymap.tag_keymap),
("Thread view", keymap.thread_keymap),
("Compose view", keymap.compose_keymap),
("Command bar", keymap.command_bar_keymap),
Expand Down
8 changes: 6 additions & 2 deletions dodo/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]),
'<enter>': ('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')),
'<space>': ('toggle marked', lambda p: [p.toggle_thread_tag('marked'), p.next_thread()]),
Expand All @@ -72,7 +72,11 @@
'G': ('last tag', lambda p: p.last_tag()),
'C-d': ('down 20', lambda p: [p.next_tag() for i in range(20)]),
'C-u': ('up 20', lambda p: [p.previous_tag() for i in range(20)]),
'<enter>': ('search tag', lambda p: p.search_current_tag()),
'<enter>': ('search with selected tag', lambda p: p.search_current_tag()),
'<space>': ('search without selected tag', lambda p: p.search_current_view()),
'N': ('narrow to tag in new panel', lambda p: p.open_current_tag()),
'n': ('narrow to tag', lambda p: p.narrow_current_tag()),
'b': ('undo last narrow to tag', lambda p: p.undo_narrow_tag()),
}
"""The local keymap for the tag panel

Expand Down
33 changes: 21 additions & 12 deletions dodo/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
from PyQt6.QtGui import QFont, QColor
import subprocess
import json
import shlex

from . import app
from . import settings
from . import util
from . import keymap
from . import thread
from . import panel
Expand Down Expand Up @@ -94,7 +96,13 @@ 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:
tag_in_q = False
for q_part in shlex.split(self.q):
# Example: shlex.split('asdf tag:blah AND tag:"blah blah"') => ['asdf', 'tag:blah', 'AND', 'tag:blah blah']
if q_part.startswith('tag:') and t == q_part.lstrip('tag:'):
tag_in_q = True
break
if t not in settings.hide_tags and not tag_in_q:
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:
Expand Down Expand Up @@ -257,27 +265,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()

Expand Down
109 changes: 92 additions & 17 deletions dodo/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,26 @@ def __init__(self) -> None:
super().__init__()
self.refresh()

def refresh(self) -> None:
def refresh(self, filter_tags: List[str]=None) -> None:
"""Refresh the model by (re-) running "notmuch search"."""
self.beginResetModel()
r = subprocess.run(['notmuch', 'search', '--output=tags', '*'],
stdout=subprocess.PIPE)
tag_query = ' AND '.join([f'tag:"{tag}"' for tag in filter_tags]) if filter_tags else ''
search_args = [tag_query] if tag_query else ['*']
cmd = ['notmuch', 'search', '--output=tags'] + search_args
r = subprocess.run(cmd, stdout=subprocess.PIPE)
tag_str = r.stdout.decode('utf-8')

self.d: List[Tuple[str,str,str]] = []

for t in tag_str.splitlines():
r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', 'tag:'+t],
stdout=subprocess.PIPE)
if filter_tags and t in filter_tags:
continue
extra_query = (' AND ' + tag_query) if tag_query else ''
cmd = ['notmuch', 'count', '--output=threads', '--', f'tag:"{t}"' + extra_query]
r1 = subprocess.run(cmd, stdout=subprocess.PIPE)
c = r1.stdout.decode('utf-8').strip()
r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', f'tag:{t} AND tag:unread'],
stdout=subprocess.PIPE)
cmd = ['notmuch', 'count', '--output=threads', '--', f'tag:"{t}" AND tag:unread' + extra_query]
r1 = subprocess.run(cmd, stdout=subprocess.PIPE)
cu = r1.stdout.decode('utf-8').strip()
self.d.append((t, cu, c))

Expand Down Expand Up @@ -135,7 +140,7 @@ def parent(self, child: QModelIndex=None) -> Any:
class TagPanel(panel.Panel):
"""A panel showing all tags"""

def __init__(self, a: app.Dodo, keep_open: bool=False, parent: Optional[QWidget]=None):
def __init__(self, a: app.Dodo, keep_open: bool=False, filter_tags: List[str]=None, parent: Optional[QWidget]=None):
super().__init__(a, keep_open, parent)
self.set_keymap(keymap.tag_keymap)
self.tree = QTreeView()
Expand All @@ -153,23 +158,43 @@ def __init__(self, a: app.Dodo, keep_open: bool=False, parent: Optional[QWidget]
if self.tree.model().rowCount() > 0:
self.tree.setCurrentIndex(self.tree.model().index(0,0))

self.filter_tags = filter_tags or []
self.last_tag_row = []
self.try_narrowing = False

def refresh(self) -> None:
"""Refresh the search listing and restore the selection, if possible."""

current = self.tree.currentIndex()
self.model.refresh()
self.model.refresh(self.filter_tags)

if self.try_narrowing:
self.try_narrowing = False
# If we're narrowing and the result is zero tags, don't do the narrow and revert to previous state
if self.model.num_tags() == 0:
last_tag = self.filter_tags.pop()
last_row = self.last_tag_row.pop()
self.model.refresh(self.filter_tags)
self.select_tag_or_nearby_row(last_tag, last_row)

# also may be sensible to open the search after narrowing as far as possible
self.search_current_tag()
return

if current.row() >= self.model.num_tags():
self.last_tag()
else:
self.tree.setCurrentIndex(current)

self.app.refresh_title()
super().refresh()

def title(self) -> str:
"""Return constant 'tags'"""

return 'tags'
"""Return constant 'tags' and the list of narrowed tags"""
t = 'tags'
for tag in self.filter_tags:
t += ' > ' + tag
return t

def next_tag(self) -> None:
"""Select the next tag
Expand Down Expand Up @@ -207,12 +232,62 @@ def search_current_tag(self) -> None:
"""Open a new search panel for the selected tag"""

tag = self.model.tag(self.tree.currentIndex())
if tag:
self.app.open_search('tag:' + tag)
tags = [tag] + self.filter_tags
tag_query = ' AND '.join([f'tag:"{tag}"' for tag in tags])
if tags:
self.app.open_search(tag_query)

def search_current_view(self) -> None:
"""Opens a new search panel for the current narrowed view, not including current selection"""
tags = self.filter_tags
tag_query = ' AND '.join([f'tag:"{tag}"' for tag in tags])
if tags:
self.app.open_search(tag_query)
else:
self.app.open_search("*")

def narrow_current_tag(self) -> None:
"""Refresh the view narrowing to the selected tag"""




ix = self.tree.currentIndex()
tag = self.model.tag(ix)
if tag:
self.filter_tags.append(tag)
self.last_tag_row.append(ix.row())
self.try_narrowing = True
self.refresh()

def open_current_tag(self) -> None:
"""Open a new tag panel narrowing to the selected tag"""
ix = self.tree.currentIndex()
tag = self.model.tag(ix)
if tag:
self.app.open_tags_narrowed(filter_tags=[tag] + self.filter_tags)

def undo_narrow_tag(self) -> None:
if self.filter_tags:
last_tag = self.filter_tags.pop()
last_row = self.last_tag_row.pop()
self.refresh()
self.select_tag_or_nearby_row(last_tag, last_row)

def select_tag_or_nearby_row(self, last_tag, last_row):
# select row if it matches the tag
row = min((last_row, self.model.num_tags() - 1))
ix = self.tree.model().index(row, 0)
if self.model.tag(ix) == last_tag:
self.tree.setCurrentIndex(ix)
return

# else index changed so find the matching tag
for row in range(self.model.num_tags()):
ix = self.tree.model().index(row, 0)
if self.model.tag(ix) == last_tag:
self.tree.setCurrentIndex(ix)
return

# else go back to the nearest index
row = min((last_row, self.model.num_tags() - 1))
ix = self.tree.model().index(row, 0)
self.tree.setCurrentIndex(ix)

22 changes: 12 additions & 10 deletions dodo/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,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)

Expand Down Expand Up @@ -476,22 +476,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()

Expand Down
22 changes: 22 additions & 0 deletions dodo/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import textwrap
from bleach.sanitizer import Cleaner
from bleach.linkifier import Linker
import shlex

from . import settings

Expand Down Expand Up @@ -515,3 +516,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