diff --git a/README.md b/README.md index cafbc86..b8e1eb1 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 @@ -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 +# 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 +# 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: @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index ffecc89..7796bcf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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(): @@ -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"] diff --git a/tests/test_template_storage.py b/tests/test_template_storage.py new file mode 100644 index 0000000..375fcfb --- /dev/null +++ b/tests/test_template_storage.py @@ -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 + diff --git a/tix/cli.py b/tix/cli.py index 3a0c10f..02e0449 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -3,6 +3,7 @@ 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 @@ -10,11 +11,11 @@ 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() @@ -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 @@ -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": @@ -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 @@ -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): @@ -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) diff --git a/tix/storage/template_storage.py b/tix/storage/template_storage.py new file mode 100644 index 0000000..337954c --- /dev/null +++ b/tix/storage/template_storage.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path +from typing import List, Optional +from tix.models import Task + + +class TemplateStorage: + """JSON-based storage for task templates""" + + def __init__(self, storage_dir: Path = None): + """Initialize template storage with default or custom path""" + self.storage_dir = storage_dir or (Path.home() / ".tix" / "templates") + self.storage_dir.mkdir(parents=True, exist_ok=True) + + def _template_path(self, name: str) -> Path: + """Return the full path for a template""" + return self.storage_dir / f"{name}.json" + + def save_template(self, task: Task, name: str): + """Save only the relevant template fields (no id / timestamps).""" + path = self._template_path(name) + data = { + "text": task.text, + "priority": task.priority, + "tags": list(task.tags) if task.tags else [], + "links": getattr(task, "links", []) or [], + } + path.write_text(json.dumps(data, indent=2)) + + def load_template(self, name: str) -> Optional[Task]: + """Load a template by name""" + path = self._template_path(name) + if not path.exists(): + return None + return json.loads(path.read_text()) + + def list_templates(self) -> List[str]: + """List all template names""" + return [p.stem for p in sorted(self.storage_dir.glob("*.json"))]