Skip to content

Commit

Permalink
add start script for i3 integration
Browse files Browse the repository at this point in the history
  • Loading branch information
vighneshiyer committed Feb 3, 2023
1 parent a1fb4b0 commit 733fe74
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 51 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,40 @@ You can also mark the task as completed, or move it around to an "Archived" sect
Just copy a task string from one Markdown file and paste it in another one.
No GUI fiddling necessary.

## i3 Integration

Just displaying the tasks that need to be done today is fine, but often we want more direction about what we should be doing *right now* in contrast to *merely today*.
There is another included CLI program `start` which given a task id, will emit the formatted task title to `/tmp/task`.
Then this task title can be displayed prominently using your window manager's notification area.

For i3 (w/ i3status and i3bar), you can add this to your `~/.config/i3status/config`:

```conf
general {
colors = true
markup = "pango"
}
order += "read_file task"
read_file task {
format = "%content"
path = "/tmp/task"
}
```

Now when you run `start <task_id>`, the task title will show up in your statusbar to remind you of what you should be working on *at this moment*.

`start` takes the same command line arguments as `today`.
If you run `start` without a task id, it will clear the task file.

You may want to include aliases to `today` and `start` for your shell:

```fish
alias t 'today --dir $HOME/task_folder'
alias s 'start --dir $HOME/task_folder'
```

## Limitations

- **Time tracking**: since there is no emphermal task state, it may be hard to record time tracking info in an automated way to the task Markdown
Expand Down Expand Up @@ -203,6 +237,12 @@ In general, I think these 'quantification' things are mostly useless and can oft
- [x] Fix task sorting with more tests
- [x] Subtasks
- [x] Cancelled tasks
- [x] Start CLI
- ~~use custom argparse Action to parse dates: https://stackoverflow.com/questions/33301000/custom-parsing-function-for-any-number-of-arguments-in-python-argparse~~
- [x] Unify argument parsing between the 2 programs
- i3 integration, use pango syntax in the /tmp/task file: https://docs.gtk.org/Pango/pango_markup.html
- [ ] Support Markdown in Start CLI
- [ ] Verify that a subtask that is due earlier than the main task shows up at the right time (when the subtask is due or has a reminder, not the main task)
- [ ] List tasks without reminders / due dates (+ be able to read from a specific Markdown file vs a directory) (to check if I missed adding due dates to something)
- [ ] Add colors for headings / paths / dates
- [ ] Recurring tasks
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ packages = [
]

[tool.poetry.scripts]
today = "today.cli:main"
today = "today.today:main"
start = "today.start:main"

[tool.poetry.dependencies]
python = "^3.9"
Expand Down
27 changes: 27 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from pathlib import Path
from datetime import date, timedelta
from today.cli import build_parser, parse_args, CliArgs


class TestCli:
parser = build_parser()

def test_cli_argparse0(self) -> None:
cli_args = parse_args(self.parser, [])
assert cli_args == CliArgs(task_dir=Path.cwd(), today=date.today(), lookahead_days=timedelta(days=0), task_id=None)

def test_cli_argparse1(self) -> None:
cli_args = parse_args(self.parser, ["--dir", "example/"])
assert cli_args == CliArgs(task_dir=Path.cwd() / "example", today=date.today(), lookahead_days=timedelta(days=0), task_id=None)

def test_cli_argparse2(self) -> None:
cli_args = parse_args(self.parser, ["--days", "10"])
assert cli_args == CliArgs(task_dir=Path.cwd(), today=date.today(), lookahead_days=timedelta(days=10), task_id=None)

def test_cli_argparse3(self) -> None:
cli_args = parse_args(self.parser, ["--today", "1/2/2022"])
assert cli_args == CliArgs(task_dir=Path.cwd(), today=date(2022, 1, 2), lookahead_days=timedelta(days=0), task_id=None)

def test_cli_argparse4(self) -> None:
cli_args = parse_args(self.parser, ["--today", "1/2/2022", "3"])
assert cli_args == CliArgs(task_dir=Path.cwd(), today=date(2022, 1, 2), lookahead_days=timedelta(days=0), task_id=3)
122 changes: 72 additions & 50 deletions today/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from pathlib import Path
import itertools
from datetime import date, timedelta
from typing import List
from typing import List, Optional
import functools
from dataclasses import dataclass

from rich.tree import Tree
from rich.console import Console
Expand All @@ -15,24 +16,65 @@
from today.output import task_should_be_displayed, task_summary, days, task_details, task_sorter


def run(args) -> None:
# Fetch Markdown task files
if args.dir:
assert Path(args.dir).is_dir()
root = Path(args.dir)
@dataclass(frozen=True)
class CliArgs:
task_dir: Path
today: date
lookahead_days: timedelta
task_id: Optional[int]

# Only display tasks that are due / have reminders up to and including this day
def task_date_filter(self) -> date:
return self.today + self.lookahead_days


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()

parser.add_argument('--dir', type=str, required=False,
help='Search for Markdown task files in this directory')
parser.add_argument('--days', type=int, required=False,
help='Look ahead this number of days in the future for tasks that have reminders or are due')
parser.add_argument('--today', type=str, required=False,
help='Use this date as today\'s date, e.g. --today 3/4/2022')
parser.add_argument('task_id', type=int, nargs='?',
help='Show the description of this specific task')
return parser


def parse_args(parser: argparse.ArgumentParser, args: List[str]) -> CliArgs:
ns = parser.parse_args(args)
if ns.dir:
task_dir = Path(ns.dir).resolve()
if not task_dir.is_dir():
raise ValueError(f"Provided --dir {ns.dir} is not a directory")
else:
task_dir = Path.cwd().resolve()

if ns.days:
lookahead_days = timedelta(days=int(ns.days))
else:
root = Path.cwd()
md_files = list(root.resolve().glob("*.md"))
lookahead_days = timedelta(days=0)

# Use the mocked 'today' date if requested, else use the actual date
if args.today:
mock_today_split = args.today.split('/')
today = date(int(mock_today_split[2]), int(mock_today_split[0]), int(mock_today_split[1]))
if ns.today:
today_split = ns.today.split('/')
today = date(int(today_split[2]), int(today_split[0]), int(today_split[1]))
else:
today = date.today()
return CliArgs(
task_dir=task_dir,
lookahead_days=lookahead_days,
today=today,
task_id=ns.task_id
)


def parse_task_files(args: CliArgs) -> List[Task]:
# Fetch Markdown task files
md_files = list(args.task_dir.glob("*.md"))

# Parse each Markdown task file
tasks_by_file: List[List[Task]] = [parse_markdown(file.read_text().split('\n'), today=today) for file in md_files]
tasks_by_file: List[List[Task]] = [parse_markdown(file.read_text().split('\n'), today=args.today) for file in md_files]

# Set each task's file path
for filepath, tasklist in zip(md_files, tasks_by_file):
Expand All @@ -43,46 +85,47 @@ def run(args) -> None:
tasks: List[Task] = list(itertools.chain(*tasks_by_file))

# Only look at tasks that have a due/reminder date on today or number of 'days' in the future
task_date_limit = today + timedelta(days=args.days)
tasks_visible: List[Task] = [task for task in tasks if task_should_be_displayed(task, task_date_limit)]
tasks_visible: List[Task] = [task for task in tasks if task_should_be_displayed(task, args.task_date_filter())]

# Sort tasks by their headings and due dates
tasks_visible.sort(key=functools.partial(task_sorter, today=today))
tasks_visible.sort(key=functools.partial(task_sorter, today=args.today))
return tasks_visible

console = Console()

def maybe_display_specific_task(args: CliArgs, tasks: List[Task], console: Console) -> None:
# If a specific task id is given, print its description and details and exit
if args.task_id is not None:
if args.task_id < 0 or args.task_id >= len(tasks_visible):
if args.task_id < 0 or args.task_id >= len(tasks):
console.print(f"The task_id {args.task_id} does not exist")
sys.exit(1)
task = tasks_visible[args.task_id]
details = task_details(task, args.task_id, today)
task = tasks[args.task_id]
details = task_details(task, args.task_id, args.today)
console.print("")
console.print(Markdown(details))
console.print("")

if len(task.subtasks) > 0:
console.print(Markdown(f"**Subtasks**:"))
console.print("")
for subtask in task.subtasks:
subtask_summary = task_summary(subtask, today)
console.print(Markdown(f"{subtask.title} {subtask_summary}"))
subtask_summary = task_summary(subtask, args.today)
console.print(Markdown(f"- {subtask.title} {subtask_summary}"))
if len(task.subtasks) > 0:
console.print("")

sys.exit(0)


def tasks_to_tree(args: CliArgs, tasks: List[Task]) -> Tree:
# Print tasks as a tree
tree = Tree(f"[bold underline]Tasks for today ({today})[/bold underline]" +
("" if args.days == 0 else f" (+{days(timedelta(days=args.days))})"))
tree = Tree(f"[bold underline]Tasks for today ({args.today})[/bold underline]" +
("" if args.lookahead_days == timedelta(0) else f" (+{days(args.lookahead_days)})"))

def add_to_tree(task: Task, tree: Tree, task_idx: int) -> Tree:
if len(task.path) == 0: # Base case
parent = tree.add(Markdown(f"**{task_idx}** - {task.title} {task_summary(task, today)}"))
parent = tree.add(Markdown(f"**{task_idx}** - {task.title} {task_summary(task, args.today)}"))
if task.subtasks:
for subtask in task.subtasks:
parent.add(Markdown(f"{subtask.title} {task_summary(subtask, today)}"))
parent.add(Markdown(f"{subtask.title} {task_summary(subtask, args.today)}"))
return tree
else:
# Try to find the first heading in the current tree's children
Expand All @@ -96,27 +139,6 @@ def add_to_tree(task: Task, tree: Tree, task_idx: int) -> Tree:
task.path = task.path[1:]
return add_to_tree(task, child, task_idx)

for i, task in enumerate(tasks_visible):
for i, task in enumerate(tasks):
add_to_tree(task, tree, i)

console.print("")
console.print(tree)
console.print("")


def main():
parser = argparse.ArgumentParser()

parser.add_argument('--dir', type=str, required=False,
help='Search for Markdown task files in this directory')
parser.add_argument('--days', type=int, default=0,
help='Look ahead this number of days in the future for tasks that have reminders or are due')
parser.add_argument('--today', type=str, required=False,
help='Use this date as today\'s date, e.g. --today 3/4/2022')
parser.add_argument('task_id', type=int, nargs='?',
help='Show the description of this specific task')

sys.exit(run(parser.parse_args()))

if __name__ == "__main__":
main()
return tree
28 changes: 28 additions & 0 deletions today/start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import sys
from pathlib import Path
import subprocess

from today.cli import build_parser, parse_args, parse_task_files


def run(args) -> None:
parser = build_parser()
cli_args = parse_args(parser, args)
task_file = Path("/tmp/task")

if cli_args.task_id is None:
task_file.write_text("")
else:
tasks = parse_task_files(cli_args)
task = tasks[cli_args.task_id]
path = " → ".join(task.path)
task_snippet = f"<span color='white'><span weight='bold'>Current Task -</span> {path} <span weight='bold'>-</span> {task.title}</span>"
Path("/tmp/task").write_text(task_snippet)
# https://i3wm.org/docs/i3status.html

subprocess.run("killall -USR1 i3status", shell=True)
sys.exit(0)


def main():
sys.exit(run(sys.argv[1:]))
23 changes: 23 additions & 0 deletions today/today.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import sys

from rich.console import Console

from today.cli import build_parser, parse_args, parse_task_files, maybe_display_specific_task, tasks_to_tree


def run(args) -> None:
parser = build_parser()
cli_args = parse_args(parser, args)
console = Console()

tasks = parse_task_files(cli_args)
maybe_display_specific_task(cli_args, tasks, console) # If a specific task is displayed, the program will exit

tree = tasks_to_tree(cli_args, tasks)
console.print("")
console.print(tree)
console.print("")


def main():
sys.exit(run(sys.argv[1:]))

0 comments on commit 733fe74

Please sign in to comment.