From 0dbe8c11ee79cd3d19b9d6d9b89fc1aeb2050194 Mon Sep 17 00:00:00 2001 From: Joan Jara Bosch Date: Thu, 2 Oct 2025 15:01:33 +0200 Subject: [PATCH 1/5] storage --- tix/cli.py | 44 ++++++++++++++++++++ tix/models.py | 6 ++- tix/storage/archive_storage.py | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tix/storage/archive_storage.py diff --git a/tix/cli.py b/tix/cli.py index 3a0c10f..741be3a 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -4,6 +4,7 @@ from pathlib import Path from tix.storage.json_storage import TaskStorage from tix.storage.context_storage import ContextStorage +from tix.storage.archive_storage import ArchiveStorage from datetime import datetime import subprocess import platform @@ -16,6 +17,7 @@ console = Console() storage = TaskStorage() context_storage = ContextStorage() +archive_storage = ArchiveStorage() @click.group(invoke_without_command=True) @@ -782,6 +784,48 @@ def interactive(show_all): from tix.commands.context import context cli.add_command(context) +@cli.command() +@click.argument("task_id", type=int) +def archive(task_id): + """Archive (soft-delete) a task""" + task = storage.get_task(task_id) + if not task: + console.print(f"[red]✗[/red] Task #{task_id} not found") + return + ArchiveStorage().add_task(task) + storage.delete_task(task_id) + console.print(f"[yellow]→[/yellow] Archived: {task.text}") + +@cli.command() +def archived(): + """List archived tasks""" + tasks = ArchiveStorage().load_tasks() + if not tasks: + console.print("[dim]No archived tasks found.[/dim]") + return + table = Table(title="Archived Tasks") + table.add_column("ID", style="cyan", width=4) + table.add_column("Task") + table.add_column("Priority", width=8) + table.add_column("Tags", style="dim") + for task in tasks: + tags_str = ", ".join(task.tags) if task.tags else "" + table.add_row(str(task.id), task.text, task.priority, tags_str) + console.print(table) + +@cli.command() +@click.argument("task_id", type=int) +def unarchive(task_id): + """Restore an archived task""" + archive = ArchiveStorage() + task = archive.get_task(task_id) + if not task: + console.print(f"[red]✗[/red] Archived task #{task_id} not found") + return + storage.add_task(task.text, task.priority, task.tags, due=task.due, is_global=task.is_global) + archive.remove_task(task_id) + console.print(f"[green]✔[/green] Restored: {task.text}") + if __name__ == '__main__': cli() diff --git a/tix/models.py b/tix/models.py index c28ce16..161d285 100644 --- a/tix/models.py +++ b/tix/models.py @@ -16,6 +16,7 @@ class Task: attachments: List[str] = field(default_factory=list) links: List[str] = field(default_factory=list) is_global: bool = False # New field for global tasks + archived: bool = False # New field for soft-deleted tasks def to_dict(self) -> dict: """Convert task to dictionary for JSON serialization""" @@ -30,7 +31,8 @@ def to_dict(self) -> dict: 'due':self.due, 'attachments': self.attachments, 'links': self.links, - 'is_global': self.is_global + 'is_global': self.is_global, + 'archived': self.archived } @classmethod @@ -45,6 +47,8 @@ def from_dict(cls, data: dict): data['links'] = [] if 'is_global' not in data: data['is_global'] = False + if 'archived' not in data: + data['archived'] = False return cls(**data) def mark_done(self): diff --git a/tix/storage/archive_storage.py b/tix/storage/archive_storage.py new file mode 100644 index 0000000..841dd63 --- /dev/null +++ b/tix/storage/archive_storage.py @@ -0,0 +1,73 @@ +import json +from pathlib import Path +from typing import List, Optional +from tix.models import Task + +class ArchiveStorage: + """JSON-based storage for archived tasks""" + def __init__(self, storage_path: Path = None, context: str = None): + self.context = context or self._get_active_context() + if storage_path: + self.storage_path = storage_path + else: + base_dir = Path.home() / ".tix" + if self.context == "default": + self.storage_path = base_dir / "archived.json" + else: + self.storage_path = base_dir / "contexts" / f"{self.context}_archived.json" + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_file() + + def _get_active_context(self) -> str: + try: + active_context_path = Path.home() / ".tix" / "active_context" + if active_context_path.exists(): + return active_context_path.read_text().strip() + except: + pass + return "default" + + def _ensure_file(self): + if not self.storage_path.exists(): + self._write_data({"tasks": []}) + + def _read_data(self) -> dict: + try: + raw = json.loads(self.storage_path.read_text()) + if isinstance(raw, dict) and "tasks" in raw: + return raw + except (json.JSONDecodeError, FileNotFoundError): + pass + return {"tasks": []} + + def _write_data(self, data: dict): + self.storage_path.write_text(json.dumps(data, indent=2)) + + def load_tasks(self) -> List[Task]: + data = self._read_data() + return [Task.from_dict(item) for item in data["tasks"]] + + def save_tasks(self, tasks: List[Task]): + data = {"tasks": [task.to_dict() for task in tasks]} + self._write_data(data) + + def add_task(self, task: Task): + tasks = self.load_tasks() + tasks.append(task) + self.save_tasks(tasks) + + def remove_task(self, task_id: int) -> Optional[Task]: + tasks = self.load_tasks() + for i, t in enumerate(tasks): + if t.id == task_id: + removed = tasks.pop(i) + self.save_tasks(tasks) + return removed + return None + + def get_task(self, task_id: int) -> Optional[Task]: + tasks = self.load_tasks() + for t in tasks: + if t.id == task_id: + return t + return None From 9d7e11fee3d092b670b3b559f098d3f245d13adb Mon Sep 17 00:00:00 2001 From: Joan Jara Bosch Date: Fri, 3 Oct 2025 10:15:54 +0200 Subject: [PATCH 2/5] fix issues --- tix/cli.py | 69 ++++++++++++++++++--------------------------------- tix/models.py | 4 --- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/tix/cli.py b/tix/cli.py index 5f4c667..64918c6 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -1,4 +1,3 @@ -# tix/cli.py -- cleaned, drop-in replacement preserving backup/restore feature import click from rich.console import Console from rich.table import Table @@ -14,65 +13,45 @@ import os import sys from .utils import get_date - -console = Console() -storage = TaskStorage() -context_storage = ContextStorage() -archive_storage = ArchiveStorage() - from .storage import storage from .config import CONFIG from .context import context_storage -# Backup helpers (new) from tix.storage.backup import create_backup, list_backups, restore_from_backup - console = Console() storage = TaskStorage() context_storage = ContextStorage() +archive_storage = ArchiveStorage() - -@click.group(invoke_without_command=True) -@click.version_option(version="0.8.0", prog_name="tix") -@click.pass_context def cli(ctx): - """⚡ TIX - Lightning-fast terminal task manager - - Quick start: - tix add "My task" -p high # Add a high priority task - tix ls # List all active tasks - tix done 1 # Mark task #1 as done - tix context list # List all contexts - tix --help # Show all commands - """ - if ctx.invoked_subcommand is None: - ctx.invoke(ls) + """ TIX - Lightning-fast terminal task manager + + Quick start: + tix add "My task" -p high # Add a high priority task + tix ls # List all active tasks + tix done 1 # Mark task #1 as done + tix context list # List all contexts + tix --help # Show all commands + """ + if ctx.invoked_subcommand is None: + ctx.invoke(ls) -# ----------------------- -# Backup CLI group -# ----------------------- -@cli.group(help="Backup and restore task data") def backup(): pass -@backup.command("create") -@click.argument("filename", required=False) -@click.option("--data-file", type=click.Path(), default=None, help="Path to tix data file (for testing/dev)") def backup_create(filename, data_file): """Create a timestamped backup of your tasks file.""" try: data_path = Path(data_file) if data_file else storage.storage_path bpath = create_backup(data_path, filename) - console.print(f"[green]✔ Backup created:[/green] {bpath}") + console.print(f"[green] Backup created:[/green] {bpath}") except Exception as e: console.print(f"[red]Backup failed:[/red] {e}") raise click.Abort() -@backup.command("list") -@click.option("--data-file", type=click.Path(), default=None, help="Path to tix data file (for testing/dev)") def backup_list(data_file): """List available backups for the active tasks file.""" try: @@ -88,10 +67,6 @@ def backup_list(data_file): raise click.Abort() -@backup.command("restore") -@click.argument("backup_file", required=True) -@click.option("--data-file", type=click.Path(), default=None, help="Path to tix data file (for testing/dev)") -@click.option("-y", "--yes", is_flag=True, help="Skip confirmation") def backup_restore(backup_file, data_file, yes): """Restore tasks from a previous backup. Will ask confirmation by default.""" try: @@ -101,7 +76,7 @@ def backup_restore(backup_file, data_file, yes): console.print("[yellow]Restore cancelled[/yellow]") return restore_from_backup(backup_file, data_path, require_confirm=False) - console.print("[green]✔ Restore complete[/green]") + console.print("[green] Restore complete[/green]") except FileNotFoundError as e: console.print(f"[red]Restore failed:[/red] {e}") raise click.Abort() @@ -1154,6 +1129,10 @@ def archive(task_id): if not task: console.print(f"[red]✗[/red] Task #{task_id} not found") return + # Check if already archived + if ArchiveStorage().get_task(task_id): + console.print(f"[yellow]![/yellow] Task #{task_id} is already archived.") + return ArchiveStorage().add_task(task) storage.delete_task(task_id) console.print(f"[yellow]→[/yellow] Archived: {task.text}") @@ -1184,15 +1163,15 @@ def unarchive(task_id): if not task: console.print(f"[red]✗[/red] Archived task #{task_id} not found") return - storage.add_task(task.text, task.priority, task.tags, due=task.due, is_global=task.is_global) + # Check for ID conflict + if storage.get_task(task_id): + console.print(f"[red]✗[/red] Cannot unarchive: Task ID #{task_id} already exists in active tasks.") + return + # Restore the original task object + storage.save_task(task) # Assumes save_task preserves ID and metadata archive.remove_task(task_id) console.print(f"[green]✔[/green] Restored: {task.text}") -if __name__ == '__main__': - cli() - console.print(f" {c}") - - if __name__ == '__main__': cli() diff --git a/tix/models.py b/tix/models.py index 07beffa..b3c3ab0 100644 --- a/tix/models.py +++ b/tix/models.py @@ -15,7 +15,6 @@ class Task: attachments: List[str] = field(default_factory=list) links: List[str] = field(default_factory=list) is_global: bool = False # New field for global tasks - archived: bool = False # New field for soft-deleted tasks links: List[str] = field(default_factory=list) def to_dict(self) -> dict: @@ -31,7 +30,6 @@ def to_dict(self) -> dict: 'attachments': self.attachments, 'links': self.links, 'is_global': self.is_global, - 'archived': self.archived } @classmethod @@ -46,8 +44,6 @@ def from_dict(cls, data: dict): data['links'] = [] if 'is_global' not in data: data['is_global'] = False - if 'archived' not in data: - data['archived'] = False return cls(**data) return cls( id=data['id'], From 145d46ecc7259ceb98bb41a38187bf23f4d3324a Mon Sep 17 00:00:00 2001 From: Joan Jara Bosch Date: Fri, 3 Oct 2025 10:45:14 +0200 Subject: [PATCH 3/5] Update cli.py --- tix/cli.py | 144 ++--------------------------------------------------- 1 file changed, 3 insertions(+), 141 deletions(-) diff --git a/tix/cli.py b/tix/cli.py index 917dda2..e86100e 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -160,108 +160,8 @@ def add(task, priority, tag, attach, link): console.print(f"[red]✗[/red] Failed to attach {file_path}: {e}") # Links - if link: - if not hasattr(new_task, "links"): - new_task.links = [] - new_task.links.extend(link) - - storage.update_task(new_task) - color = {'high': 'red', 'medium': 'yellow', 'low': 'green'}[priority] - 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]") - - -@cli.command() -@click.option("--all", "-a", "show_all", is_flag=True, help="Show completed tasks too") -def ls(show_all): - """List all tasks""" - from tix.config import CONFIG - - tasks = storage.load_tasks() if show_all else storage.get_active_tasks() - - if not tasks: - console.print("[dim]No tasks found. Use 'tix add' to create one![/dim]") - return - - # Get display settings from config - display_config = CONFIG.get('display', {}) - show_ids = display_config.get('show_ids', True) - show_dates = display_config.get('show_dates', False) - compact_mode = display_config.get('compact_mode', False) - max_text_length = display_config.get('max_text_length', 0) - - # color settings - priority_colors = CONFIG.get('colors', {}).get('priority', {}) - status_colors = CONFIG.get('colors', {}).get('status', {}) - tag_color = CONFIG.get('colors', {}).get('tags', 'cyan') - - title = "All Tasks" if show_all else "Tasks" - table = Table(title=title) - if show_ids: - table.add_column("ID", style="cyan", width=4) - table.add_column("✔", width=3) - table.add_column("Priority", width=8) - table.add_column("Task") - if not compact_mode: - table.add_column("Tags", style=tag_color) - if show_dates: - table.add_column("Created", style="dim") - - count = dict() - - 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 "" - - attach_icon = " 📎" if getattr(task, "attachments", None) or getattr(task, "links", None) else "" - - # text truncation - text_val = getattr(task, "text", getattr(task, "task", "")) - if max_text_length and max_text_length > 0 and len(text_val) > max_text_length: - text_val = text_val[: max_text_length - 3] + "..." - - task_style = "dim strike" if getattr(task, "completed", False) else "" - row = [] - if show_ids: - row.append(str(getattr(task, "id", ""))) - row.append(status) - row.append(f"[{priority_color}]{getattr(task, 'priority', '')}[/{priority_color}]") - if getattr(task, "completed", False): - row.append(f"[{task_style}]{text_val}[/{task_style}]{attach_icon}") - else: - row.append(f"{text_val}{attach_icon}") - if not compact_mode: - row.append(tags_str) - if show_dates: - created = getattr(task, "created", getattr(task, "created_at", None)) - if created: - try: - created_date = datetime.fromisoformat(created).strftime('%Y-%m-%d') - row.append(created_date) - except: - row.append("") - else: - row.append("") - table.add_row(*row) - count[getattr(task, "completed", False)] = count.get(getattr(task, "completed", False), 0) + 1 - - console.print(table) - if not compact_mode: - console.print("\n") - console.print(f"[cyan]Total tasks:{sum(count.values())}") - console.print(f"[cyan]Active tasks:{count.get(False, 0)}") - console.print(f"[green]Completed tasks:{count.get(True, 0)}") - - if show_all: - active = len([t for t in tasks if not getattr(t, "completed", False)]) - completed = len([t for t in tasks if getattr(t, "completed", False)]) - console.print(f"\n[dim]Total: {len(tasks)} | Active: {active} | Completed: {completed}[/dim]") + # ...existing code... @cli.command() @@ -400,7 +300,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)') -def edit(task_id, text, priority, add_tag, remove_tag, attach, link): +def edit(task_id, text, priority, add_tag, remove_tag, attach, link, due): """Edit a task""" task = storage.get_task(task_id) if not task: @@ -714,44 +614,6 @@ def filter(priority, tag, completed): filter_desc = " AND ".join(filters) if filters else "all" console.print(f"[bold]{len(tasks)} task(s) matching [{filter_desc}]:[/bold]\n") - if not completed: - tasks = [t for t in tasks if not getattr(t, "completed", False)] - q = query.lower() - results = [t for t in tasks if q in getattr(t, "text", getattr(t, "task", "")).lower()] - if tag: - results = [t for t in results if tag in getattr(t, "tags", [])] - if priority: - results = [t for t in results if getattr(t, "priority", None) == priority] - if not results: - console.print(f"[dim]No tasks matching '{query}'[/dim]") - return - 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, - ) - - for t in results: - status = "✔" if getattr(t, "completed", False) else "○" - priority_color = {"high": "red", "medium": "yellow", "low": "green"}.get(getattr(t, "priority", "medium"), "yellow") - tags_str = ", ".join(getattr(t, "tags", [])) if getattr(t, "tags", None) else "" - ttext = getattr(t, "text", getattr(t, "task", "")) - highlighted = ttext.replace(query, f"[bold yellow]{query}[/bold yellow]") if query.lower() in ttext.lower() else ttext - table.add_row(str(getattr(t, "id", "")), status, f"[{priority_color}]{getattr(t, 'priority', '')}[/{priority_color}]", highlighted, tags_str) - console.print(table) @cli.command() @@ -1028,7 +890,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(), + report_data = {'generated': datetime.now().isoformat(), 'context': context_storage.get_active_context(), '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) From 6e636548cd32dc85103bc6f3f455df72fceec120 Mon Sep 17 00:00:00 2001 From: Joan Jara Bosch Date: Fri, 3 Oct 2025 18:19:39 +0200 Subject: [PATCH 4/5] purged --- tix/cli.py | 356 +++----------------------------------------------- tix/models.py | 19 +-- tix/utils.py | 18 +++ 3 files changed, 43 insertions(+), 350 deletions(-) create mode 100644 tix/utils.py diff --git a/tix/cli.py b/tix/cli.py index c277242..2d67b67 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -1,3 +1,5 @@ +cli = click.Group() +from tix.utils import get_date import click from rich.console import Console from rich.table import Table @@ -12,50 +14,31 @@ import platform import os import sys -from .utils import get_date -from .storage import storage -from .config import CONFIG -from .context import context_storage +import click from rich.console import Console from rich.table import Table from pathlib import Path from tix.storage.json_storage import TaskStorage from tix.storage.context_storage import ContextStorage -from tix.storage.history import HistoryManager -from tix.storage.backup import create_backup, list_backups, restore_from_backup -from tix.models import Task +from tix.storage.archive_storage import ArchiveStorage from rich.prompt import Prompt from rich.markdown import Markdown from datetime import datetime -from .storage import storage -from .config import CONFIG -from .context import context_storage +import subprocess +import platform +import os +import sys +from tix.utils import get_date +from tix.storage.history import HistoryManager +from tix.storage.backup import create_backup, list_backups, restore_from_backup +from tix.models import Task console = Console() storage = TaskStorage() context_storage = ContextStorage() archive_storage = ArchiveStorage() - history = HistoryManager() - -@click.group(invoke_without_command=True) -@click.version_option(version="0.8.0", prog_name="tix") -@click.pass_context -def cli(ctx): - """ TIX - Lightning-fast terminal task manager - - Quick start: - tix add "My task" -p high # Add a high priority task - tix ls # List all active tasks - tix done 1 # Mark task #1 as done - tix context list # List all contexts - tix --help # Show all commands - """ - if ctx.invoked_subcommand is None: - ctx.invoke(ls) - - # ----------------------- # Backup CLI group # ----------------------- @@ -706,107 +689,6 @@ def move(from_id, to_id): console.print(f"[green]✔[/green] Moved task from #{from_id} to #{to_id}") -@cli.command() -@click.argument("query") -@click.option("--tag", "-t", help="Filter by tag") -@click.option( - "--priority", "-p", type=click.Choice(["low", "medium", "high"]), help="Filter by priority" -) -@click.option("--priority", "-p", type=click.Choice(["low", "medium", "high"]), help="Filter by priority") -@click.option("--completed", "-c", is_flag=True, help="Search in completed tasks") -def search(query, tag, priority, completed): - """Search tasks by text""" - tasks = storage.load_tasks() - - # Filter by completion status - if not completed: - tasks = [t for t in tasks if not t.completed] - - # Filter by query text (case-insensitive) - query_lower = query.lower() - results = [t for t in tasks if query_lower in t.text.lower()] - - # Filter by tag if specified - if tag: - results = [t for t in results if tag in t.tags] - - # Filter by priority if specified - if priority: - results = [t for t in results if t.priority == priority] - - if not results: - console.print(f"[dim]No tasks matching '{query}'[/dim]") - return - - console.print(f"[bold]Found {len(results)} task(s) matching '{query}':[/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 results: - status = "✔" if task.completed else "○" - priority_color = {"high": "red", "medium": "yellow", "low": "green"}[task.priority] - tags_str = ", ".join(task.tags) if task.tags else "" - - # Highlight matching text - highlighted_text = ( - task.text.replace(query, f"[bold yellow]{query}[/bold yellow]") - if query.lower() in task.text.lower() - else task.text - ) - - table.add_row( - str(task.id), - status, - f"[{priority_color}]{task.priority}[/{priority_color}]", - highlighted_text, - tags_str, - ) - - 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") - - @cli.command() @click.option("--priority", "-p", type=click.Choice(["low", "medium", "high"]), help="Filter by priority") @@ -864,57 +746,6 @@ def filter(priority, tag, completed): console.print(table) -@cli.command() -@click.option("--no-tags", is_flag=True, help="Show tasks without tags") -def tags(no_tags): - """List all unique tags or tasks without tags""" - tasks = storage.load_tasks() - - if no_tags: - # Show tasks without tags - untagged = [t for t in tasks if not t.tags] - if not untagged: - console.print("[dim]All tasks have tags[/dim]") - return - - console.print(f"[bold]{len(untagged)} task(s) without tags:[/bold]\n") - for task in untagged: - status = "✔" if task.completed else "○" - console.print(f"{status} #{task.id}: {task.text}") - else: - # Show all unique tags with counts - tag_counts = {} - for task in tasks: - for tag in task.tags: - tag_counts[tag] = tag_counts.get(tag, 0) + 1 - - if not tag_counts: - console.print("[dim]No tags found[/dim]") - return - - console.print("[bold]Tags in use:[/bold]\n") - for tag, count in sorted(tag_counts.items(), key=lambda x: (-x[1], x[0])): - console.print(f" • {tag} ({count} task{'s' if count != 1 else ''})") - if no_tags: - untagged = [t for t in tasks if not getattr(t, "tags", [])] - if not untagged: - console.print("[dim]All tasks have tags[/dim]") - return - console.print(f"[bold]{len(untagged)} task(s) without tags:[/bold]\n") - for t in untagged: - status = "✔" if getattr(t, "completed", False) else "○" - console.print(f"{status} #{getattr(t,'id','')}: {getattr(t,'text',getattr(t,'task',''))}") - else: - tag_counts = {} - for t in tasks: - for tg in getattr(t, "tags", []): - tag_counts[tg] = tag_counts.get(tg, 0) + 1 - if not tag_counts: - console.print("[dim]No tags found[/dim]") - return - console.print("[bold]Tags in use:[/bold]\n") - for tg, cnt in sorted(tag_counts.items(), key=lambda x: (-x[1], x[0])): - console.print(f" • {tg} ({cnt} task{'s' if cnt != 1 else ''})") @cli.command() @@ -966,159 +797,6 @@ def stats(detailed): console.print(f" • {day}: {len(by_day[day])} task(s)") -@cli.command() -@click.option('--format', '-f', type=click.Choice(['text', 'json','markdown']), default='text', help='Output format') -@click.option('--output', '-o', type=click.Path(), help='Output to file') -def report(format, output): - """Generate a task report""" - tasks = storage.load_tasks() - - if not tasks: - console.print("[dim]No tasks to report[/dim]") - return - - active = [t for t in tasks if not t.completed] - completed = [t for t in tasks if t.completed] - - if format == "json": - import json - - report_data = { - 'generated': datetime.now().isoformat(), - 'context': context_storage.get_active_context(), - '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) - elif format == 'markdown': - report_lines = [ - "# TIX Task Report", - "", - f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}", - "", - "## Summary", - "", - f"- **Total Tasks:** {len(tasks)}", - f"- **Active:** {len(active)}", - f"- **Completed:** {len(completed)}", - "" - ] - priority_order = ['high', 'medium', 'low'] - active_by_priority = {p: [] for p in priority_order} - for task in active: - active_by_priority[task.priority].append(task) - - report_lines.extend([ - "## Active Tasks", - "", - ]) - for priority in priority_order: - tasks_in_priority = active_by_priority[priority] - if tasks_in_priority: - priority_emoji = {'high': '🔴', 'medium': '🟡', 'low': '🟢'} - report_lines.append(f"### {priority_emoji[priority]} {priority.capitalize()} Priority") - report_lines.append("") - - for task in tasks_in_priority: - tags = f" `{', '.join(task.tags)}`" if task.tags else "" - report_lines.append(f"- [ ] **#{task.id}** {task.text}{tags}") - - report_lines.append("") - if completed: - report_lines.extend([ - "## Completed Tasks", - "", - "| ID | Task | Priority | Tags | Completed At |", - "|---|---|---|---|---|" - ]) - for task in completed: - tags = ", ".join([f"`{tag}`" for tag in task.tags]) if task.tags else "-" - completed_date = datetime.fromisoformat(task.completed_at).strftime('%Y-%m-%d %H:%M') if task.completed_at else "-" - priority_emoji = {'high': '🔴', 'medium': '🟡', 'low': '🟢'} - report_lines.append( - f"| #{task.id} | ~~{task.text}~~ | {priority_emoji[task.priority]} {task.priority} | {tags} | {completed_date} |" - ) - report_lines.append("") - report_text = "\n".join(report_lines) - else: - # Text format - active_context = context_storage.get_active_context() - report_lines = [ - "TIX TASK REPORT", - "=" * 40, - f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", - f"Context: {active_context}", - "", - f"Total Tasks: {len(tasks)}", - f"Active: {len(active)}", - f"Completed: {len(completed)}", - "", - "ACTIVE TASKS:", - "-" * 20, - ] - - for task in active: - tags = f" [{', '.join(task.tags)}]" if task.tags else "" - global_marker = " (global)" if task.is_global else "" - report_lines.append(f"#{task.id} [{task.priority}] {task.text}{tags}{global_marker}") - - report_lines.extend(["", "COMPLETED TASKS:", "-" * 20]) - - for task in completed: - tags = f" [{', '.join(task.tags)}]" if task.tags else "" - global_marker = " (global)" if task.is_global else "" - report_lines.append(f"#{task.id} ✔ {task.text}{tags}{global_marker}") - - report_text = "\n".join(report_lines) - - if not tasks: - console.print("[dim]No tasks to report[/dim]") - return - active = [t for t in tasks if not getattr(t, "completed", False)] - 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(), - '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) - elif format == 'markdown': - lines = ["# TIX Task Report", "", f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}", "", "## Summary", "", f"- **Total Tasks:** {len(tasks)}", f"- **Active:** {len(active)}", f"- **Completed:** {len(completed)}", ""] - for t in active: - tags = f" `{', '.join(getattr(t,'tags',[]))}`" if getattr(t,'tags',None) else "" - lines.append(f"- [ ] **#{getattr(t,'id','')}** {getattr(t,'text',getattr(t,'task',''))}{tags}") - if completed: - lines.append("") - lines.append("## Completed Tasks") - lines.append("") - lines.append("| ID | Task | Priority | Tags | Completed At |") - lines.append("|---|---|---|---|---|") - for t in completed: - tags = ", ".join([f"`{x}`" for x in getattr(t,'tags',[])]) if getattr(t,'tags',None) else "-" - comp = getattr(t,'completed_at', "-") - lines.append(f"| #{getattr(t,'id','')} | ~~{getattr(t,'text',getattr(t,'task',''))}~~ | {getattr(t,'priority','')} | {tags} | {comp} |") - report_text = "\n".join(lines) - else: - lines = ["TIX TASK REPORT", "="*40, f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", "", f"Total Tasks: {len(tasks)}", f"Active: {len(active)}", f"Completed: {len(completed)}", "", "ACTIVE TASKS:", "-"*20] - for t in active: - tags = f" [{', '.join(getattr(t,'tags',[]))}]" if getattr(t,'tags',None) else "" - lines.append(f"#{getattr(t,'id','')} [{getattr(t,'priority','')}] {getattr(t,'text',getattr(t,'task',''))}{tags}") - lines.append("") - lines.append("COMPLETED TASKS:") - lines.append("-"*20) - for t in completed: - tags = f" [{', '.join(getattr(t,'tags',[]))}]" if getattr(t,'tags',None) else "" - lines.append(f"#{getattr(t,'id','')} ✔ {getattr(t,'text',getattr(t,'task',''))}{tags}") - report_text = "\n".join(lines) - if output: - Path(output).write_text(report_text) - console.print(f"[green]✔[/green] Report saved to {output}") - else: - console.print(report_text) @@ -1371,8 +1049,14 @@ def unarchive(task_id): if storage.get_task(task_id): console.print(f"[red]✗[/red] Cannot unarchive: Task ID #{task_id} already exists in active tasks.") return - # Restore the original task object - storage.save_task(task) # Assumes save_task preserves ID and metadata + # Restore the original task object using supported methods + tasks = storage.load_tasks() + tasks.append(task) + if hasattr(storage, "save_tasks"): + storage.save_tasks(tasks) + else: + for t in tasks: + storage.update_task(t) archive.remove_task(task_id) console.print(f"[green]✔[/green] Restored: {task.text}") diff --git a/tix/models.py b/tix/models.py index b3c3ab0..7240233 100644 --- a/tix/models.py +++ b/tix/models.py @@ -35,26 +35,17 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict): """Create task from dictionary (handles old tasks safely)""" - # Handle legacy tasks without new fields - if 'due' not in data: - data['due'] = None - if 'attachments' not in data: - data['attachments'] = [] - if 'links' not in data: - data['links'] = [] - if 'is_global' not in data: - data['is_global'] = False - return cls(**data) return cls( - id=data['id'], - text=data['text'], + id=data.get('id'), + text=data.get('text'), priority=data.get('priority', 'medium'), completed=data.get('completed', False), created_at=data.get('created_at', datetime.now().isoformat()), completed_at=data.get('completed_at'), tags=data.get('tags', []), - attachments=data.get('attachments', []), - links=data.get('links', []) + attachments=data.get('attachments', []), + links=data.get('links', []), + is_global=data.get('is_global', False) ) def mark_done(self): diff --git a/tix/utils.py b/tix/utils.py new file mode 100644 index 0000000..eb4c399 --- /dev/null +++ b/tix/utils.py @@ -0,0 +1,18 @@ +from datetime import datetime + +def get_date(date_str): + """Parse a date string in ISO or common formats. Returns ISO string or None if invalid.""" + if not date_str: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"): + try: + dt = datetime.strptime(date_str, fmt) + return dt.isoformat() + except Exception: + continue + # Try parsing as ISO format + try: + dt = datetime.fromisoformat(date_str) + return dt.isoformat() + except Exception: + return None From 8710df8310960c247d7743a8a9e1fefc9821bf31 Mon Sep 17 00:00:00 2001 From: Joan Jara Bosch Date: Fri, 3 Oct 2025 18:29:35 +0200 Subject: [PATCH 5/5] solving bugs and duplicateds --- tix/cli.py | 18 ++---------------- tix/storage/context_storage.py | 16 ++++++++++++++++ tix/utils/__init__.py | 0 tix/utils/utils.py | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 tix/storage/context_storage.py delete mode 100644 tix/utils/__init__.py create mode 100644 tix/utils/utils.py diff --git a/tix/cli.py b/tix/cli.py index 2d67b67..81651a2 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -1,20 +1,4 @@ -cli = click.Group() from tix.utils import get_date -import click -from rich.console import Console -from rich.table import Table -from pathlib import Path -from tix.storage.json_storage import TaskStorage -from tix.storage.context_storage import ContextStorage -from tix.storage.archive_storage import ArchiveStorage -from rich.prompt import Prompt -from rich.markdown import Markdown -from datetime import datetime -import subprocess -import platform -import os -import sys - import click from rich.console import Console from rich.table import Table @@ -34,6 +18,8 @@ from tix.storage.backup import create_backup, list_backups, restore_from_backup from tix.models import Task +cli = click.Group() + console = Console() storage = TaskStorage() context_storage = ContextStorage() diff --git a/tix/storage/context_storage.py b/tix/storage/context_storage.py new file mode 100644 index 0000000..d24651d --- /dev/null +++ b/tix/storage/context_storage.py @@ -0,0 +1,16 @@ +class ContextStorage: + """Minimal stub for context storage. Extend as needed.""" + def __init__(self): + self._active_context = None + + def get_active_context(self): + return self._active_context or "default" + + def set_active_context(self, context): + self._active_context = context + + def list_contexts(self): + return ["default"] + + def __repr__(self): + return f"" diff --git a/tix/utils/__init__.py b/tix/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tix/utils/utils.py b/tix/utils/utils.py new file mode 100644 index 0000000..eb4c399 --- /dev/null +++ b/tix/utils/utils.py @@ -0,0 +1,18 @@ +from datetime import datetime + +def get_date(date_str): + """Parse a date string in ISO or common formats. Returns ISO string or None if invalid.""" + if not date_str: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"): + try: + dt = datetime.strptime(date_str, fmt) + return dt.isoformat() + except Exception: + continue + # Try parsing as ISO format + try: + dt = datetime.fromisoformat(date_str) + return dt.isoformat() + except Exception: + return None