Skip to content
Merged
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
255 changes: 196 additions & 59 deletions tix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@
from rich.table import Table
from pathlib import Path
from tix.storage.json_storage import TaskStorage
from tix.storage.context_storage import ContextStorage
from datetime import datetime
import subprocess
import platform
import os
import sys
from .utils import get_date

# Backup helpers (new)
from tix.storage.backup import create_backup, list_backups, restore_from_backup

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


@click.group(invoke_without_command=True)
Expand Down Expand Up @@ -132,10 +128,6 @@ def restore(backup_file, data_file, yes):
raise click.Abort()


# -----------------------
# End backup group
# -----------------------

@cli.command()
@click.argument('task')
@click.option('--priority', '-p', default='medium',
Expand All @@ -144,26 +136,20 @@ def restore(backup_file, data_file, yes):
@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):
def add(task, priority, tag, attach, link):
"""Add a new task"""
from tix.config import CONFIG

if not task or not task.strip():
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)

# merge default tags from config
default_tags = CONFIG.get('defaults', {}).get('tags', [])
tags = list(default_tags) + list(tag)
tags = list(dict.fromkeys(tags)) # preserve order, unique

new_task = storage.add_task(task, priority, tags, due=date, is_global=is_global)
new_task = storage.add_task(task, priority, tags)

# Handle attachments
if attach:
Expand All @@ -190,18 +176,12 @@ def add(task, priority, tag, attach, link, due, is_global):
storage.update_task(new_task)

color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[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}")
console.print(f"[green]✔[/green] Added task #{new_task.id}: [{color}]{task}[/{color}]")
if tags:
console.print(f"[dim] Tags: {', '.join(tags)}[/dim]")
if attach or link:
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":
console.print(f"[dim] Context: {active_context}[/dim]")


@cli.command()
@click.option("--all", "-a", "show_all", is_flag=True, help="Show completed tasks too")
Expand Down Expand Up @@ -238,31 +218,15 @@ def ls(show_all):
table.add_column("Tags", style=tag_color)
if show_dates:
table.add_column("Created", style="dim")
table.add_column("Due Date")
table.add_column("Scope", style="dim", width=6)

count = dict()
now = datetime.now().date()

for task in sorted(tasks, key=lambda t: (getattr(t, "completed", False), getattr(t, "id", 0))):
status = "✔" if getattr(task, "completed", False) else "○"
priority_color = priority_colors.get(getattr(task, "priority", "medium"),
{'high': 'red', 'medium': 'yellow', 'low': 'green'}[getattr(task, "priority", "medium")])
tags_str = ", ".join(getattr(task, "tags", [])) if getattr(task, "tags", None) else ""
due_date_str = ""
if getattr(task, "due", None):
try:
due_date = datetime.strptime(getattr(task, "due"), r"%Y-%m-%d").date()
if due_date < now and not getattr(task, "completed", False):
due_date_str = f"[red]{getattr(task, 'due')}[/red]"
elif due_date == now:
due_date_str = f"[yellow]{getattr(task, 'due')}[/yellow]"
else:
due_date_str = getattr(task, "due")
except Exception:
due_date_str = getattr(task, "due")

scope = "global" if getattr(task, "is_global", False) else "local"
attach_icon = " 📎" if getattr(task, "attachments", None) or getattr(task, "links", None) else ""

# text truncation
Expand All @@ -284,12 +248,14 @@ def ls(show_all):
row.append(tags_str)
if show_dates:
created = getattr(task, "created", getattr(task, "created_at", None))
if created and hasattr(created, "strftime"):
row.append(created.strftime("%Y-%m-%d"))
if created:
try:
created_date = datetime.fromisoformat(created).strftime('%Y-%m-%d')
row.append(created_date)
except:
row.append("")
else:
row.append(str(created) if created else "")
row.append(due_date_str)
row.append(scope)
row.append("")
table.add_row(*row)
count[getattr(task, "completed", False)] = count.get(getattr(task, "completed", False), 0) + 1

Expand Down Expand Up @@ -442,8 +408,7 @@ def clear(completed, force):
@click.option('--remove-tag', multiple=True, help='Remove tags')
@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")
def edit(task_id, text, priority, add_tag, remove_tag, attach, link, due):
def edit(task_id, text, priority, add_tag, remove_tag, attach, link):
"""Edit a task"""
task = storage.get_task(task_id)
if not task:
Expand All @@ -469,14 +434,6 @@ def edit(task_id, text, priority, add_tag, remove_tag, attach, link, due):
if tag in getattr(task, "tags", []):
task.tags.remove(tag)
changes.append(f"-tag: '{tag}'")
if due:
new_date = get_date(due)
if new_date:
old_date = getattr(task, "due", None)
task.due = new_date
changes.append(f"due date: {old_date} → {new_date}")
else:
console.print("[red]Error processing due date[/red]")

if attach:
attachment_dir = Path.home() / ".tix" / "attachments" / str(task.id)
Expand Down Expand Up @@ -515,6 +472,25 @@ def edit(task_id, text, priority, add_tag, remove_tag, attach, link, due):
console.print("[yellow]No changes made[/yellow]")


@cli.command()
@click.argument("task_id", type=int)
def undo(task_id):
"""Mark a completed task as active again"""
task = storage.get_task(task_id)
if not task:
console.print(f"[red]✗[/red] Task #{task_id} not found")
return

if not task.completed:
console.print(f"[yellow]![/yellow] Task #{task_id} is not completed")
return

task.completed = False
task.completed_at = None
storage.update_task(task)
console.print(f"[green]✔[/green] Reactivated: {task.text}")


@cli.command(name="done-all")
@click.argument("task_ids", nargs=-1, type=int, required=True)
def done_all(task_ids):
Expand Down Expand Up @@ -624,6 +600,62 @@ def search(query, tag, priority, completed):
console.print(table)


@cli.command()
@click.option("--priority", "-p", type=click.Choice(["low", "medium", "high"]), help="Filter by priority")
@click.option("--tag", "-t", help="Filter by tag")
@click.option("--completed/--active", "-c/-a", default=None, help="Filter by completion status")
def filter(priority, tag, completed):
"""Filter tasks by criteria"""
tasks = storage.load_tasks()

# Apply filters
if priority:
tasks = [t for t in tasks if t.priority == priority]

if tag:
tasks = [t for t in tasks if tag in t.tags]

if completed is not None:
tasks = [t for t in tasks if t.completed == completed]

if not tasks:
console.print("[dim]No matching tasks[/dim]")
return

# Build filter description
filters = []
if priority:
filters.append(f"priority={priority}")
if tag:
filters.append(f"tag='{tag}'")
if completed is not None:
filters.append("completed" if completed else "active")

filter_desc = " AND ".join(filters) if filters else "all"
console.print(f"[bold]{len(tasks)} task(s) matching [{filter_desc}]:[/bold]\n")

table = Table()
table.add_column("ID", style="cyan", width=4)
table.add_column("✓", width=3)
table.add_column("Priority", width=8)
table.add_column("Task")
table.add_column("Tags", style="dim")

for task in sorted(tasks, key=lambda t: (t.completed, t.id)):
status = "✓" if task.completed else "○"
priority_color = {"high": "red", "medium": "yellow", "low": "green"}[task.priority]
tags_str = ", ".join(task.tags) if task.tags else ""
table.add_row(
str(task.id),
status,
f"[{priority_color}]{task.priority}[/{priority_color}]",
task.text,
tags_str,
)

console.print(table)


@cli.command()
@click.option("--no-tags", is_flag=True, help="Show tasks without tags")
def tags(no_tags):
Expand Down Expand Up @@ -689,7 +721,7 @@ def report(format, output):
completed = [t for t in tasks if getattr(t, "completed", False)]
if format == "json":
import json
report_data = {'generated': datetime.now().isoformat(), 'context': context_storage.get_active_context(),
report_data = {'generated': datetime.now().isoformat(),
'summary': {'total': len(tasks), 'active': len(active), 'completed': len(completed)},
'tasks': [t.to_dict() for t in tasks]}
report_text = json.dumps(report_data, indent=2)
Expand Down Expand Up @@ -779,10 +811,115 @@ def interactive(show_all):
app.run()


# Import and register context commands
from tix.commands.context import context
cli.add_command(context)
@cli.group()
def config():
"""Manage TIX configuration settings"""
pass


@config.command('init')
def config_init():
"""Initialize configuration file with defaults"""
from tix.config import create_default_config_if_not_exists, get_config_path

if create_default_config_if_not_exists():
console.print(f"[green]✔[/green] Created default config at {get_config_path()}")
else:
console.print(f"[yellow]![/yellow] Config file already exists at {get_config_path()}")


@config.command('show')
@click.option('--key', '-k', help='Show specific config key (e.g., defaults.priority)')
def config_show(key):
"""Show current configuration"""
from tix.config import load_config, get_config_value, get_config_path
import yaml

if key:
value = get_config_value(key)
if value is None:
console.print(f"[red]✗[/red] Config key '{key}' not found")
else:
console.print(f"[cyan]{key}:[/cyan] {value}")
else:
config = load_config()
console.print(f"[bold]Configuration from {get_config_path()}:[/bold]\n")
console.print(yaml.dump(config, default_flow_style=False, sort_keys=False))


@config.command('set')
@click.argument('key')
@click.argument('value')
def config_set(key, value):
"""Set a configuration value (e.g., tix config set defaults.priority high)"""
from tix.config import set_config_value

# Try to parse value as YAML to support different types
import yaml
try:
parsed_value = yaml.safe_load(value)
except yaml.YAMLError:
parsed_value = value

if set_config_value(key, parsed_value):
console.print(f"[green]✔[/green] Set {key} = {parsed_value}")
else:
console.print(f"[red]✗[/red] Failed to set configuration")


@config.command('get')
@click.argument('key')
def config_get(key):
"""Get a configuration value"""
from tix.config import get_config_value

value = get_config_value(key)
if value is None:
console.print(f"[red]✗[/red] Config key '{key}' not found")
else:
console.print(f"{value}")


@config.command('reset')
@click.option('--confirm', '-y', is_flag=True, help='Skip confirmation')
def config_reset(confirm):
"""Reset configuration to defaults"""
from tix.config import DEFAULT_CONFIG, save_config, get_config_path

if not confirm:
if not click.confirm("Are you sure you want to reset configuration to defaults?"):
console.print("[yellow]⚠ Cancelled[/yellow]")
return

if save_config(DEFAULT_CONFIG):
console.print(f"[green]✔[/green] Reset configuration to defaults at {get_config_path()}")
else:
console.print(f"[red]✗[/red] Failed to reset configuration")


@config.command('path')
def config_path():
"""Show path to configuration file"""
from tix.config import get_config_path
console.print(get_config_path())


@config.command('edit')
def config_edit():
"""Open configuration file in default editor"""
from tix.config import get_config_path, create_default_config_if_not_exists

create_default_config_if_not_exists()
config_path = get_config_path()

editor = os.environ.get('EDITOR', 'nano')
try:
subprocess.run([editor, config_path])
console.print(f"[green]✔[/green] Configuration edited")
except Exception as e:
console.print(f"[red]✗[/red] Failed to open editor: {e}")
console.print(f"[dim]Try: export EDITOR=vim or export EDITOR=nano[/dim]")


if __name__ == '__main__':
cli()
cli()