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: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ tix add "My task" # Start managing tasks!
- **Auto Shell Completion**: Tab completion works out of the box for bash, zsh, and fish
- **Attachments & Links**: Attach files or add reference URLs to tasks
- **Open Attachments**: Use `tix open <id>` to quickly open all files/links for a task
- **Task Templates**: Save common tasks as templates and reuse them for quick creation

## About Interactive (TUI) Mode

Expand Down Expand Up @@ -350,6 +351,32 @@ tix report -f json -o tasks.json
tix report --output my-tasks.txt
```

#### Task Templates

```bash
# Save an existing task as a template
tix template save <task_id> <template_name>
# Example: save task #2 as 'pr-template'
tix template save 2 pr-template

# List all saved templates
tix template list

# Create a new task from a template
tix add --template <template_name>
# Example: create a task from 'pr-template'
tix add --template pr-template

# Override template values when creating a new task
tix add --template pr-template --priority high --tag urgent

# Templates are stored in ~/.tix/templates/

# If a template does not exist, tix will notify you

# Each add --template creates a new task with the template fields
```

## 🎨 Using Tab Completion

Tab completion works automatically after installation:
Expand Down Expand Up @@ -410,6 +437,9 @@ Example structure:
| `stats` | Show statistics | `tix stats -d` |
| `report` | Generate report | `tix report -f json -o tasks.json` |
| `open` | Open attachments and links for a task | `tix open 1` |
| `template save` | Save a task as a template | `tix template save 2 pr-template` |
| `template list` | List all saved templates | `tix template list` |
| `add --template` | Create a new task from a template | `tix add --template pr-template -p high -t urgent` |

## 🗑️ Uninstalling TIX

Expand Down
66 changes: 66 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from pathlib import Path
from unittest.mock import patch, MagicMock

from tix.storage.json_storage import TaskStorage
from tix.storage.template_storage import TemplateStorage
from tix.models import Task
import tix.cli


@pytest.fixture
def runner():
Expand Down Expand Up @@ -308,3 +313,64 @@ def test_open_command_opens_attachments_and_links(runner, tmp_path):
# Ensure the link was opened (regardless of platform details)
calls = [str(call_args[0][0][1]) for call_args in mock_popen.call_args_list]
assert "https://example.com" in calls


def test_template_save_and_list(runner, tmp_path):
"""Test template save and list CLI commands"""

tasks_file = tmp_path / "tasks.json"
tasks_file.write_text('{"next_id": 1, "tasks": []}')
task_storage = TaskStorage(tasks_file)
template_storage = TemplateStorage(storage_dir=tmp_path)

task = task_storage.add_task("My task", priority="high")

# Patch module-level storage objects in tix.cli
with patch.object(tix.cli, "storage", task_storage), \
patch.object(tix.cli, "template_storage", template_storage):

# Save template
result = runner.invoke(tix.cli.cli, ["template", "save", str(task.id), "my-template"])
assert result.exit_code == 0
assert "Saved task" in result.output
assert "my-template" in result.output

# List templates
result = runner.invoke(tix.cli.cli, ["template", "list"])
assert result.exit_code == 0
assert "my-template" in result.output

def test_add_task_from_template(runner):
"""Test adding a task using a saved template"""
with runner.isolated_filesystem():
# Create empty storage
temp_storage = Path.cwd() / "tasks.json"
temp_storage.write_text('[]')

test_storage = TaskStorage(temp_storage)

# Fake template data
fake_template = {
"text": "Template task",
"priority": "high",
"tags": ["work", "urgent"],
"links": ["https://example.com"]
}

# Patch both storage and template_storage
with patch('tix.cli.storage', test_storage), \
patch('tix.cli.template_storage.load_template', return_value=fake_template):

# Run CLI with template
result = runner.invoke(cli, ['add', '--template', 'my_template'])
assert result.exit_code == 0
assert 'Added task' in result.output
assert 'Template task' in result.output

# Verify task in storage
task = test_storage.get_task(1)
assert task is not None
assert task.text == "Template task"
assert task.priority == "high"
assert set(task.tags) == {"work", "urgent"}
assert task.links == ["https://example.com"]
46 changes: 46 additions & 0 deletions tests/test_template_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import pytest
from tix.models import Task
from tix.storage.template_storage import TemplateStorage


@pytest.fixture
def temp_template_dir(tmp_path):
"""Fixture providing an isolated template storage directory"""
storage = TemplateStorage(storage_dir=tmp_path)
return storage


def test_save_and_load_template(temp_template_dir):
"""Test saving a template and then loading it"""
task = Task(id=1, text="Template task", priority="high", tags=["urgent"], completed=False)
task.links = ["https://example.com"]

temp_template_dir.save_template(task, "my-template")

path = temp_template_dir._template_path("my-template")
assert path.exists(), "Template file should exist after saving"

loaded = temp_template_dir.load_template("my-template")
assert loaded["text"] == task.text
assert loaded["priority"] == task.priority
assert loaded["tags"] == list(task.tags)
assert loaded["links"] == task.links


def test_load_missing_template(temp_template_dir):
"""Loading a non-existent template should return None"""
assert temp_template_dir.load_template("missing") is None


def test_list_templates(temp_template_dir):
"""List all saved templates"""
# Save multiple templates
task = Task(id=1, text="Task1", priority="low", tags=[])
temp_template_dir.save_template(task, "t1")
temp_template_dir.save_template(task, "t2")

templates = temp_template_dir.list_templates()
assert "t1" in templates
assert "t2" in templates
assert len(templates) == 2

128 changes: 99 additions & 29 deletions tix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
from rich.table import Table
from pathlib import Path
from tix.storage.json_storage import TaskStorage
from tix.storage.template_storage import TemplateStorage
from tix.storage.context_storage import ContextStorage
from datetime import datetime
import subprocess
import platform
import os
import sys
from .utils import get_date
from datetime import datetime
from importlib import import_module

console = Console()
storage = TaskStorage()
template_storage = TemplateStorage()
context_storage = ContextStorage()


Expand All @@ -36,33 +37,60 @@ def cli(ctx):


@cli.command()
@click.argument('task')
@click.option('--priority', '-p', default='medium',
@click.argument('task', required=False) # can be omitted if using a template
@click.option('--priority', '-p', default=None,
type=click.Choice(['low', 'medium', 'high']),
help='Set task priority')
help='Set task priority (overrides template)')
@click.option('--tag', '-t', multiple=True, help='Add tags to task')
@click.option('--attach', '-f', multiple=True, help='Attach file(s)')
@click.option('--link', '-l', multiple=True, help='Attach URL(s)')
@click.option("--due", "-d", help="Due date of task")
@click.option('--global', 'is_global', is_flag=True, help='Make task visible in all contexts')
def add(task, priority, tag, attach, link, due, is_global):
"""Add a new task"""
if not task or not task.strip():
@click.option('--template', '-T', help='Create task from saved template')
def add(task, priority, tag, attach, link, due, is_global, template):
"""Add a new task (optionally from a saved template)."""

# --- Load template if requested ---
template_data = {}
if template:
template_data = template_storage.load_template(template)
if not template_data:
console.print(f"[red]✗[/red] Template '{template}' not found")
sys.exit(1)

# --- Build final values (template provides defaults, CLI overrides them) ---
text = (task.strip() if isinstance(task, str) and task.strip() else None) or template_data.get("text")
if not text:
console.print("[red]✗[/red] Task text cannot be empty")
sys.exit(1)
date = get_date(due)
if due and not date:
console.print("[red]Error processing date")
sys.exit(1)

new_task = storage.add_task(task, priority, list(tag), due=date, is_global=is_global)
# Handle attachments
chosen_priority = priority if priority is not None else template_data.get("priority", "medium")

template_tags = template_data.get("tags", []) if template_data else []
tags = list(dict.fromkeys(template_tags + list(tag))) # dedupe while preserving order

template_links = template_data.get("links", []) if template_data else []
links = template_links + list(link)

date = None
if due:
parsed = get_date(due)
if not parsed:
console.print("[red]Error processing date[/red]")
sys.exit(1)
date = parsed
else:
date = template_data.get("due") if template_data else None

new_task = storage.add_task(text, chosen_priority, tags, due=date, is_global=is_global)

# --- Attachments (CLI only for now) ---
if attach:
attachment_dir = Path.home() / ".tix" / "attachments" / str(new_task.id)
attachment_dir.mkdir(parents=True, exist_ok=True)
for file_path in attach:
try:
src = Path(file_path).expanduser().resolve()
src = Path(file_path).expanduser().resolve()
if not src.exists():
console.print(f"[red]✗[/red] File not found: {file_path}")
continue
Expand All @@ -72,21 +100,20 @@ def add(task, priority, tag, attach, link, due, is_global):
except Exception as e:
console.print(f"[red]✗[/red] Failed to attach {file_path}: {e}")

# Handle links
if link:
new_task.links.extend(link)
if links:
new_task.links = links

# Save updated task (with attachments/links)
storage.update_task(new_task)

color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[priority]

color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[chosen_priority]
global_indicator = " [dim](global)[/dim]" if is_global else ""
console.print(f"[green]✔[/green] Added task #{new_task.id}: [{color}]{task}[/{color}]{global_indicator}")
if tag:
console.print(f"[dim] Tags: {', '.join(tag)}[/dim]")
if attach or link:
console.print(f"[green]✔[/green] Added task #{new_task.id}: [{color}]{text}[/{color}]{global_indicator}")
if tags:
console.print(f"[dim] Tags: {', '.join(tags)}[/dim]")
if attach or links:
console.print(f"[dim] Attachments/Links added[/dim]")

# Show current context if not default
active_context = context_storage.get_active_context()
if active_context != "default":
Expand Down Expand Up @@ -334,13 +361,19 @@ def edit(task_id, text, priority, add_tag, remove_tag, attach, link, due):
console.print("[red]Error updating due date. Try again with proper format")
# Handle attachments
if attach:
attachment_dir = Path.home() / ".tix/attachments" / str(task.id)
attachment_dir = Path.home() / ".tix" / "attachments" / str(task.id)
attachment_dir.mkdir(parents=True, exist_ok=True)
for file_path in attach:
src = Path(file_path)
dest = attachment_dir / src.name
dest.write_bytes(src.read_bytes())
task.attachments.append(str(dest))
try:
src = Path(file_path).expanduser().resolve()
if not src.exists():
console.print(f"[red]✗[/red] File not found: {file_path}")
continue
dest = attachment_dir / src.name
dest.write_bytes(src.read_bytes())
task.attachments.append(str(dest))
except Exception as e:
console.print(f"[red]✗[/red] Failed to attach {file_path}: {e}")
changes.append(f"attachments added: {[Path(f).name for f in attach]}")

# Handle links
Expand Down Expand Up @@ -765,6 +798,7 @@ def safe_open(path_or_url, is_link=False):
for url in task.links:
safe_open(url, is_link=True)


@cli.command()
@click.option('--all', '-a', 'show_all', is_flag=True, help='Show completed tasks too')
def interactive(show_all):
Expand All @@ -778,6 +812,42 @@ def interactive(show_all):
app.run()


@click.group()
def template():
"""Manage task templates"""
pass


@template.command("save")
@click.argument("task_id", type=int)
@click.argument("name")
def save_template(task_id, name):
"""Save a task as a template"""
task = storage.get_task(task_id)
if not task:
console.print(f"[red]✗[/red] Task {task_id} not found")
return

template_storage.save_template(task, name)
console.print(f"[green]✔[/green] Saved task {task_id} as template '{name}'")


@template.command("list")
def list_templates():
"""List all saved templates"""
templates = template_storage.list_templates()
if not templates:
console.print("[dim]No templates saved yet[/dim]")
return

console.print("[bold]Templates:[/bold]")
for t in templates:
console.print(f" • {t}")


# Register group with main CLI
cli.add_command(template)

# Import and register context commands
from tix.commands.context import context
cli.add_command(context)
Expand Down
Loading