From 9bd6ba97797faf4dad2aa7c23d08a6c883bd64bf Mon Sep 17 00:00:00 2001 From: codefromlani Date: Wed, 1 Oct 2025 17:11:10 +0100 Subject: [PATCH 1/7] chore: resolve merge conflict --- tix/cli.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tix/cli.py b/tix/cli.py index 5425398..68ce3dc 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 datetime import datetime import subprocess import platform @@ -14,6 +15,7 @@ # Initialize console and storage console = Console() storage = TaskStorage() +template_storage = TemplateStorage() @click.group(invoke_without_command=True) @@ -665,6 +667,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): @@ -677,5 +680,43 @@ def interactive(show_all): app = Tix(show_all=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) + + if __name__ == '__main__': cli() From e85d73db4293dc33c3043d99c963feed716af8a0 Mon Sep 17 00:00:00 2001 From: codefromlani Date: Wed, 1 Oct 2025 17:30:59 +0100 Subject: [PATCH 2/7] feat: add TemplateStorage for saving and listing task templates --- tix/storage/template_storage.py | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tix/storage/template_storage.py diff --git a/tix/storage/template_storage.py b/tix/storage/template_storage.py new file mode 100644 index 0000000..9616812 --- /dev/null +++ b/tix/storage/template_storage.py @@ -0,0 +1,47 @@ +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"))] + + def delete_template(self, name: str) -> bool: + """Delete a template by name""" + path = self._template_path(name) + if path.exists(): + path.unlink() + return True + return False From 9fee223217d53680d2abde4a9bf781570126b566 Mon Sep 17 00:00:00 2001 From: codefromlani Date: Thu, 2 Oct 2025 11:40:18 +0100 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20add=20template=20support=20for=20?= =?UTF-8?q?=E2=9C=97=20Task=20text=20cannot=20be=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tix/cli.py | 89 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 29 deletions(-) diff --git a/tix/cli.py b/tix/cli.py index 845b88f..02e0449 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -11,7 +11,6 @@ import os import sys from .utils import get_date -from datetime import datetime from importlib import import_module console = Console() @@ -38,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 @@ -74,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": @@ -336,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 From 5edaa5a0aea057025d5619576f133746be5f5e55 Mon Sep 17 00:00:00 2001 From: codefromlani Date: Thu, 2 Oct 2025 12:25:39 +0100 Subject: [PATCH 4/7] docs: add task template usage and examples to README --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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 From 4d8c658a64a2a20b4ba460c3f6dfdd13a335433e Mon Sep 17 00:00:00 2001 From: codefromlani Date: Thu, 2 Oct 2025 13:14:27 +0100 Subject: [PATCH 5/7] refactor: remove unused delete_template() method --- tix/storage/template_storage.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tix/storage/template_storage.py b/tix/storage/template_storage.py index 9616812..337954c 100644 --- a/tix/storage/template_storage.py +++ b/tix/storage/template_storage.py @@ -37,11 +37,3 @@ def load_template(self, name: str) -> Optional[Task]: def list_templates(self) -> List[str]: """List all template names""" return [p.stem for p in sorted(self.storage_dir.glob("*.json"))] - - def delete_template(self, name: str) -> bool: - """Delete a template by name""" - path = self._template_path(name) - if path.exists(): - path.unlink() - return True - return False From d2e692b0739c56c1db1908aad2a5de1c34ab2d56 Mon Sep 17 00:00:00 2001 From: codefromlani Date: Thu, 2 Oct 2025 13:15:56 +0100 Subject: [PATCH 6/7] test: add pytest coverage for TemplateStorage save, load, and list --- tests/test_template_storage.py | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_template_storage.py 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 + From 4877371dab5b55c92e3b7fed1fe16fcac4796f77 Mon Sep 17 00:00:00 2001 From: codefromlani Date: Thu, 2 Oct 2025 13:31:47 +0100 Subject: [PATCH 7/7] test: add CLI tests for template save, list, and add commands --- tests/test_cli.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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"]