Skip to content
Merged
Show file tree
Hide file tree
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
71 changes: 71 additions & 0 deletions src/faff_cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
faff-cli configuration.

Read from the [faff-cli] section of the ledger's config.toml.
All settings default to True if the section is absent.
"""

from __future__ import annotations

import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import List

import toml


ALL_FIELDS = ["title", "role", "subject", "impact", "mode", "trackers", "note"]


@dataclass
class SessionConfig:
title: bool = True
role: bool = True
impact: bool = True
mode: bool = True
subject: bool = True
trackers: bool = True
note: bool = True
max_trackers: int = 0 # 0 = unlimited, 1 = at most one, etc.

def enabled_fields(self) -> List[str]:
"""Return field names in canonical order, filtered to enabled ones."""
return [f for f in ALL_FIELDS if getattr(self, f)]


@dataclass
class CliConfig:
session: SessionConfig = field(default_factory=SessionConfig)


def _ledger_config_path() -> Path:
faff_dir = os.environ.get("FAFF_DIR")
root = Path(faff_dir) if faff_dir else Path.home() / ".faff"
return root / "config.toml"


def load_config() -> CliConfig:
path = _ledger_config_path()
if not path.exists():
return CliConfig()

try:
data = toml.load(path)
except Exception:
return CliConfig()

cli_data = data.get("faff-cli", {})
session_data = cli_data.get("session", {})
session = SessionConfig(
title=session_data.get("title", True),
role=session_data.get("role", True),
impact=session_data.get("impact", True),
mode=session_data.get("mode", True),
subject=session_data.get("subject", True),
trackers=session_data.get("trackers", True),
note=session_data.get("note", True),
max_trackers=session_data.get("max_trackers", 0),
)

return CliConfig(session=session)
57 changes: 29 additions & 28 deletions src/faff_cli/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
from faff_cli.output import create_formatter
from faff_cli.filtering import parse_simple_filters, apply_filters

app = typer.Typer(help="Manage ASTRO fields (actions, subjects, trackers, roles, objectives)")
app = typer.Typer(help="Manage ASTRO fields (modes, subjects, trackers, roles, impacts)")

VALID_FIELDS = ["role", "objective", "action", "subject", "tracker"]
VALID_FIELDS = ["role", "impact", "mode", "subject", "tracker"]
PLURAL_MAP = {
"role": "roles",
"objective": "objectives",
"action": "actions",
"impact": "impacts",
"mode": "modes",
"subject": "subjects",
"tracker": "trackers",
}
Expand All @@ -21,7 +21,7 @@
@app.command()
def list(
ctx: typer.Context,
field: str = typer.Argument(..., help="Field to list (role, objective, action, subject, tracker)"),
field: str = typer.Argument(..., help="Field to list (role, impact, mode, subject, tracker)"),
filter_strings: List[str] = typer.Argument(
None,
help="Filters: field=value (exact), field~value (contains), field!=value (not equal)",
Expand All @@ -45,14 +45,14 @@ def list(
"""
List unique values for a ASTRO field.

Shows field values from both plan-level collections and intents, with usage counts.
Shows field values from both plan-level collections and sessions, with usage counts.
Results are sorted by usage (most used first).

Examples:
faff field list role
faff field list action value~meeting
faff field list role intents>10
faff field list objective --json
faff field list mode value~meeting
faff field list role sessions>10
faff field list impact --json
"""
if field not in VALID_FIELDS:
typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True)
Expand All @@ -76,25 +76,28 @@ def list(
today = ws.today()
if field == "role":
all_defined = set(ws.plans.get_roles(today))
elif field == "objective":
all_defined = set(ws.plans.get_objectives(today))
elif field == "action":
all_defined = set(ws.plans.get_actions(today))
elif field == "impact":
all_defined = set(ws.plans.get_impacts(today))
elif field == "mode":
all_defined = set(ws.plans.get_modes(today))
elif field == "subject":
all_defined = set(ws.plans.get_subjects(today))
elif field == "tracker":
all_defined = set(ws.plans.get_trackers(today).keys())
else:
all_defined = set()

# Get intent counts from plans via Rust
intent_count = ws.plans.get_field_usage_stats(field)

# Get session counts and log dates from logs via Rust
session_count, log_dates_dict = ws.logs.get_field_usage_stats(field)

# Combine all values: defined in plans + used in intents/sessions
values = all_defined | set(intent_count.keys()) | set(session_count.keys())
# Combine all values: defined in plans + used in sessions
values = all_defined | set(session_count.keys())

# Build plan membership: value → source name (from value prefix, if in current plan)
plan_membership = {}
for value in all_defined:
source = value.split(":")[0] if ":" in value else "local"
plan_membership[value] = source

# Convert log_dates_dict values (lists of PyDate) to count of unique logs
log_count = {}
Expand All @@ -109,13 +112,12 @@ def list(
# Build field data list
field_data = []
for value in values:
intents = intent_count.get(value, 0)
sessions = session_count.get(value, 0)
logs = log_count.get(value, 0)

row = {
"value": value,
"intents": intents,
"plan": plan_membership.get(value, ""),
"sessions": sessions,
"logs": logs,
}
Expand All @@ -131,7 +133,7 @@ def list(
field_data = apply_filters(field_data, filters)

# Sort by usage (most used first = most sessions)
field_data.sort(key=lambda x: (x["sessions"], x["intents"], x["logs"]), reverse=True)
field_data.sort(key=lambda x: (x["sessions"], x["logs"]), reverse=True)

# Apply limit
if limit:
Expand All @@ -146,14 +148,14 @@ def list(
columns = [
("value", "Value", "cyan"),
("name", "Name", "yellow"),
("intents", "Intents", "green"),
("plan", "In Plan", "yellow"),
("sessions", "Sessions", "green"),
("logs", "Logs", "green"),
]
else:
columns = [
("value", "Value", "cyan"),
("intents", "Intents", "green"),
("plan", "In Plan", "yellow"),
("sessions", "Sessions", "green"),
("logs", "Logs", "green"),
]
Expand Down Expand Up @@ -182,7 +184,7 @@ def list(
@app.command()
def replace(
ctx: typer.Context,
field: str = typer.Argument(..., help="Field to replace (role, objective, action, subject)"),
field: str = typer.Argument(..., help="Field to replace (role, impact, mode, subject)"),
old_value: str = typer.Argument(..., help="Old value to replace"),
new_value: str = typer.Argument(..., help="New value"),
):
Expand All @@ -191,8 +193,7 @@ def replace(

This will:
- Update the field in plan-level ASTRO collections
- Update all intents that use the old value
- Update all log sessions that reference those intents
- Update all log sessions that use the old value
"""
if field not in VALID_FIELDS:
typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True)
Expand All @@ -207,10 +208,10 @@ def replace(
console = Console()

# Update plans via Rust layer
plans_updated, intents_updated = ws.plans.replace_field_in_all_plans(
plans_updated = ws.plans.replace_field_in_all_plans(
field, old_value, new_value
)
console.print(f"[green]Updated {intents_updated} intent(s) across {plans_updated} plan(s)[/green]")
console.print(f"[green]Updated {plans_updated} plan(s)[/green]")

# Update logs via Rust layer
import datetime
Expand Down
4 changes: 2 additions & 2 deletions src/faff_cli/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ def summary(ctx: typer.Context, date: str = typer.Argument(None)):
if stats['mean_reflection_score'] is not None:
output += f"Mean reflection score: {stats['mean_reflection_score']:.2f}/5\n"

output += "\nIntent Totals:\n"
for alias, minutes in stats['by_intent'].items():
output += "\nAlias Totals:\n"
for alias, minutes in stats['by_alias'].items():
output += f"- {alias}: {humanize.precisedelta(datetime.timedelta(minutes=minutes), minimum_unit='minutes')}\n"

output += "\nTracker Totals:\n"
Expand Down
Loading
Loading