diff --git a/README.md b/README.md index 4aa8958..f4644f2 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,54 @@ tix backup restore ``` +# πŸ“– Filters + +#### Saved Filters (Saved Searches) + +You can save commonly used filters so you don’t have to re-type them every time. + +```bash +# Save a filter named "work" for high-priority tasks tagged "work" +tix filter save work -t work -p high + +# Save filter for completed tasks +tix filter save done-only --completed + +# Overwrite an existing filter (with --force) +tix filter save work -t work -p medium --force +```` + +#### Listing Saved Filters + +```bash +# Show all saved filters +tix filter list +``` + +Example output: + +``` +Saved Filters: + β€’ work β†’ priority=high AND tag='work' + β€’ done-only β†’ completed +``` + +#### Applying Saved Filters + +```bash +# Apply a saved filter +tix filter apply --saved work + +# Apply directly without saving +tix filter apply -p high -t urgent + +# Saved filter takes precedence over inline flags +tix filter apply --saved work -p low # will still use the saved 'work' filter +``` + +⚑ Saved filters are stored in `~/.tix/filters.json`. +You can edit/remove the file manually, but it’s recommended to use the CLI commands. + ## 🎨 Using Tab Completion Tab completion works automatically after installation: diff --git a/tix/cli.py b/tix/cli.py index 1461e84..1198a55 100644 --- a/tix/cli.py +++ b/tix/cli.py @@ -21,6 +21,11 @@ console = Console() storage = TaskStorage() + +from typing import Optional, Dict, Any +import json + + context_storage = ContextStorage() history = HistoryManager() @@ -687,62 +692,186 @@ def search(query, tag, priority, completed): console.print(table) -@cli.command() +# ---- Saved filters: replace the old `filter` command with this group ---- +from typing import Optional, Dict, Any +import json + +FILTERS_PATH = Path.home() / ".tix" / "filters.json" +FILTERS_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def _load_saved_filters() -> Dict[str, Dict[str, Any]]: + """Return mapping name -> filter-params""" + if not FILTERS_PATH.exists(): + return {} + try: + with FILTERS_PATH.open("r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + return {} + except Exception: + return {} + + +def _save_saved_filters(filters: Dict[str, Dict[str, Any]]) -> bool: + try: + with FILTERS_PATH.open("w", encoding="utf-8") as f: + json.dump(filters, f, indent=2, sort_keys=True) + return True + except Exception: + return False + + +@cli.group() +def filter(): + """Manage and apply saved filters""" + pass + + +@filter.command("apply") @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() +@click.option("--saved", "-s", "saved_name", help="Apply a saved filter by name") +def filter_apply(priority: Optional[str], tag: Optional[str], completed: Optional[bool], saved_name: Optional[str]): + """ + Apply a filter (immediately). Use --saved to apply saved filters. + If --saved is provided, any inline options are ignored (saved filter takes precedence). + """ + # If saved filter requested, load and override CLI params + if saved_name: + saved = _load_saved_filters().get(saved_name) + if not saved: + console.print(f"[red]βœ—[/red] Saved filter '{saved_name}' not found") + return + priority = saved.get("priority") + tag = saved.get("tag") + completed = saved.get("completed") + + # Now perform filtering (same UX as previous 'filter' command) + tasks = storage.load_tasks() if hasattr(storage, "load_tasks") else [] + + # completion filter: None = all, True = completed, False = active + if completed is not None: + tasks = [t for t in tasks if getattr(t, "completed", False) == completed] - # Apply filters if priority: - tasks = [t for t in tasks if t.priority == priority] + tasks = [t for t in tasks if getattr(t, "priority", None) == 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] + tasks = [t for t in tasks if tag in getattr(t, "tags", [])] if not tasks: console.print("[dim]No matching tasks[/dim]") return # Build filter description - filters = [] + filters_desc = [] if priority: - filters.append(f"priority={priority}") + filters_desc.append(f"priority={priority}") if tag: - filters.append(f"tag='{tag}'") + filters_desc.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" + filters_desc.append("completed" if completed else "active") + filter_desc = " AND ".join(filters_desc) if filters_desc 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("βœ”", 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 "" + 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 = {"high": "red", "medium": "yellow", "low": "green"}.get(getattr(task, "priority", "medium"), "yellow") + tags_str = ", ".join(getattr(task, "tags", [])) if getattr(task, "tags", None) else "" table.add_row( - str(task.id), + str(getattr(task, "id", "")), status, - f"[{priority_color}]{task.priority}[/{priority_color}]", - task.text, + f"[{priority_color}]{getattr(task, 'priority', '')}[/{priority_color}]", + getattr(task, "text", getattr(task, "task", "")), tags_str, ) console.print(table) +@filter.command("save") +@click.argument("name") +@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") +@click.option("--force", "-f", is_flag=True, help="Overwrite existing saved filter of same name") +def filter_save(name: str, priority: Optional[str], tag: Optional[str], completed: Optional[bool], force: bool): + """ + Save a filter under . Later you can apply it with `tix filter apply --saved `. + Example: tix filter save work -t work -p high + """ + filters = _load_saved_filters() + if name in filters and not force: + console.print(f"[red]βœ—[/red] A saved filter named '{name}' already exists. Use --force to overwrite.") + return + + storage_obj = { + "priority": priority, + "tag": tag, + # store completed as True/False/null + "completed": None if completed is None else (True if completed else False), + "saved_at": datetime.now().isoformat() + } + + # Remove empty keys + storage_obj = {k: v for k, v in storage_obj.items() if v is not None} + filters[name] = storage_obj + if _save_saved_filters(filters): + console.print(f"[green]βœ”[/green] Saved filter '{name}'") + # quick usage hint + parts = [] + if "priority" in storage_obj: + parts.append(f"-p {storage_obj['priority']}") + if "tag" in storage_obj: + parts.append(f"-t {storage_obj['tag']}") + if "completed" in storage_obj: + parts.append("--completed" if storage_obj["completed"] else "--active") + if parts: + console.print(f"[dim]Use: tix filter apply --saved {name} (equivalent: tix filter apply {' '.join(parts)})[/dim]") + else: + console.print(f"[red]βœ—[/red] Failed to save filter '{name}'") + + +@filter.command("list") +def filter_list(): + """List saved filters""" + filters = _load_saved_filters() + if not filters: + console.print("[dim]No saved filters[/dim]") + return + + table = Table(title="Saved Filters") + table.add_column("Name", style="cyan") + table.add_column("Filter", style="dim") + table.add_column("Saved At", style="green", width=22) + + for name, obj in sorted(filters.items(), key=lambda x: x[0]): + parts = [] + if "priority" in obj: + parts.append(f"priority={obj['priority']}") + if "tag" in obj: + parts.append(f"tag='{obj['tag']}'") + if "completed" in obj: + parts.append("completed" if obj["completed"] else "active") + filter_desc = " AND ".join(parts) if parts else "all" + saved_at = obj.get("saved_at", "-") + table.add_row(name, filter_desc, saved_at) + + console.print(table) +# ---- end saved filters ---- + + + @cli.command() @click.option("--no-tags", is_flag=True, help="Show tasks without tags") def tags(no_tags):