@@ -37,6 +37,7 @@ ALIASES = {
'progress': ['p', 'pr', 'prog'],
'rm-version': ['rmv'],
'todo-list': ['tl'],
+ 'workflow-list': ['wl'],
'unarchive': ['unar'],
}
@@ -501,6 +502,12 @@ def add_workflow_subparser(subparsers):
aliases=ALIASES['todo-list'])
tlist.add_argument('--all', action='store_true', dest='show_all',
help='Show all scheduled todos, not just due ones')
+
+ wlist = workflow_subparsers.add_parser('list',
+ aliases=[*ALIASES['workflow-list'],
+ 'ls'])
+ wlist.add_argument('-a', '--all', action='store_true', dest='show_all',
+ help='Include archived entries')
return workflow
@@ -365,6 +365,8 @@ def do_workflow(args, test_db=None):
workflow.todo(cser, args.series, args.days)
elif args.subcmd == 'todo-list':
workflow.todo_list(cser, args.show_all)
+ elif args.subcmd in ['list', 'wl', 'ls']:
+ workflow.list_entries(cser, args.show_all)
else:
raise ValueError(f"Unknown workflow subcommand '{args.subcmd}'")
finally:
@@ -1130,3 +1130,26 @@ class Database: # pylint:disable=R0904
query += ' ORDER BY w.timestamp'
res = self.execute(query, params)
return res.fetchall()
+
+ def workflow_list(self, include_archived=False):
+ """Get workflow entries joined with series info
+
+ Args:
+ include_archived (bool): True to include archived entries
+
+ Return:
+ list of tuple:
+ str: workflow type
+ str: series name
+ str: series description
+ str: timestamp
+ int: archived flag (0 or 1)
+ """
+ query = ('SELECT w.type, s.name, s.desc, w.timestamp, w.archived '
+ 'FROM workflow w '
+ 'JOIN series s ON w.series_id = s.id')
+ if not include_archived:
+ query += ' WHERE w.archived = 0'
+ query += ' ORDER BY w.timestamp'
+ res = self.execute(query)
+ return res.fetchall()
@@ -4323,3 +4323,75 @@ Date: .*
ts = cser.db.workflow_get('todo', ser.idnum)
self.assertEqual('2025-03-12 12:00:00', ts)
+
+ def test_workflow_list(self):
+ """Test listing all workflow entries"""
+ cser = self.get_cser()
+ with terminal.capture():
+ cser.add('first', 'my description', allow_unmarked=True)
+
+ cser.fake_now = datetime(2025, 3, 1, 12, 0, 0)
+
+ # Record a send (creates SENT + TODO)
+ ser = cser.get_series_by_name('first')
+ wf.sent(cser, ser.idnum)
+
+ # Default list shows only active entries
+ with terminal.capture() as (out, _):
+ wf.list_entries(cser, show_all=False)
+ lines = out.getvalue().splitlines()
+ self.assertEqual(4, len(lines))
+ self.assertIn('sent', lines[2])
+ self.assertIn('todo', lines[3])
+
+ # Archive the todo
+ wf.todo_clear(cser, 'first')
+
+ # Without --all, only SENT is active; no 'A' column
+ with terminal.capture() as (out, _):
+ wf.list_entries(cser, show_all=False)
+ lines = out.getvalue().splitlines()
+ self.assertEqual(3, len(lines))
+ self.assertIn('sent', lines[2])
+ self.assertNotIn('A', lines[0])
+
+ # With --all, archived entries appear with '*' marker
+ with terminal.capture() as (out, _):
+ wf.list_entries(cser, show_all=True)
+ lines = out.getvalue().splitlines()
+ self.assertGreater(len(lines), 3)
+ self.assertIn(' A ', lines[0])
+ has_archived = any('*' in line for line in lines[2:])
+ self.assertTrue(has_archived)
+
+ def test_friendly_time(self):
+ """Test friendly timestamp formatting"""
+ now = datetime(2025, 3, 10, 15, 0, 0) # Monday
+
+ # Same day
+ when = datetime(2025, 3, 10, 9, 30, 0)
+ self.assertEqual('09:30', wf.friendly_time(now, when))
+
+ # Earlier this week (3 days ago = Friday)
+ when = datetime(2025, 3, 7, 14, 20, 0)
+ self.assertEqual('Fri 14:20', wf.friendly_time(now, when))
+
+ # 10 days ago
+ when = datetime(2025, 2, 28, 10, 0, 0)
+ self.assertEqual('10d ago', wf.friendly_time(now, when))
+
+ # 3 weeks ago
+ when = datetime(2025, 2, 17, 10, 0, 0)
+ self.assertEqual('3w ago', wf.friendly_time(now, when))
+
+ # Future within a week (3 days from now = Thursday)
+ when = datetime(2025, 3, 13, 16, 0, 0)
+ self.assertEqual('Thu 16:00', wf.friendly_time(now, when))
+
+ # Future 10 days
+ when = datetime(2025, 3, 20, 10, 0, 0)
+ self.assertEqual('in 10d', wf.friendly_time(now, when))
+
+ # Future 3 weeks
+ when = datetime(2025, 3, 31, 10, 0, 0)
+ self.assertEqual('in 3w', wf.friendly_time(now, when))
@@ -15,6 +15,34 @@ class Wtype(enum.StrEnum):
TODO = 'todo'
+def friendly_time(now, when):
+ """Format a timestamp in a human-friendly way
+
+ Args:
+ now (datetime): Current time
+ when (datetime): Timestamp to format
+
+ Return:
+ str: Friendly string, e.g. 'Tue 15:34', '3d ago', '2w ago'
+ """
+ delta = now - when
+ days = delta.days
+ if days < 0:
+ days = -days
+ if days >= 14:
+ return f'in {days // 7}w'
+ if days >= 7:
+ return f'in {days}d'
+ return when.strftime('%a %H:%M')
+ if days == 0:
+ return when.strftime('%H:%M')
+ if days < 7:
+ return when.strftime('%a %H:%M')
+ if days < 14:
+ return f'{days}d ago'
+ return f'{days // 7}w ago'
+
+
def sent(cser, series_id):
"""Record that a series was sent and create a follow-up todo
@@ -92,3 +120,34 @@ def todo_list(cser, show_all):
else:
due = f'in {days}d'
print(f"{name:17} {due:>14} {desc}")
+
+
+def list_entries(cser, show_all):
+ """List all workflow entries
+
+ Args:
+ cser (CseriesHelper): Series helper with open database
+ show_all (bool): True to include archived entries
+ """
+ entries = cser.db.workflow_list(include_archived=show_all)
+ if not entries:
+ print('No workflow entries')
+ return
+ hdr = f"{'Type':6} {'Series':17} {'When':>10}"
+ div = f"{'-' * 6} {'-' * 17} {'-' * 10}"
+ if show_all:
+ hdr += ' A'
+ div += ' -'
+ hdr += ' Description'
+ div += ' ' + '-' * 30
+ print(hdr)
+ print(div)
+ now = cser.get_now()
+ for wtype, name, desc, ts, archived in entries:
+ when = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
+ friendly = friendly_time(now, when)
+ line = f"{wtype:6} {name:17} {friendly:>10}"
+ if show_all:
+ line += f" {'*' if archived else ' '}"
+ line += f" {desc}"
+ print(line)