feat: projects command — find the project IDs the config is missing
Reading gizmo_id during normalization fixed attribution, but it cannot answer "which projects am I missing?": normalization only runs on conversations being exported, and a normal run skips everything already cached. Discovering the gaps would have meant --force re-exporting the whole archive. `ai-chat-exporter projects` does it directly. It lists conversations, collects the g-p- ids they belong to, resolves display names, and prints a table marking which are absent from .env plus a paste-ready CHATGPT_PROJECT_IDS line. --write applies it; --deep falls back to one detail request per conversation when the listing does not carry gizmo_id (unverified which shape this account returns, so the command reports which path it took rather than assuming). This matters beyond tidiness: attribution is now self-correcting, but the listing pass still needs the ids. Conversations that live only inside a project never appear in the default listing, so an unlisted project's chats are not merely misfiled — they are never fetched. 6 CLI tests: reporting, the already-configured case, the g-p- guard, --deep, the hint when --deep is needed, and --write. 324 pass.
This commit is contained in:
+137
@@ -332,6 +332,143 @@ def doctor(ctx: click.Context) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--deep",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Fetch each conversation's detail when the listing does not name its "
|
||||
"project. Slow (one request per conversation) but complete."
|
||||
),
|
||||
)
|
||||
@click.option("--write", is_flag=True, help="Write the discovered IDs to .env.")
|
||||
@click.pass_context
|
||||
def projects(ctx: click.Context, deep: bool, write: bool) -> None:
|
||||
"""Discover ChatGPT project IDs, including ones missing from your config.
|
||||
|
||||
CHATGPT_PROJECT_IDS has to be maintained by hand, and a project missing
|
||||
from it is invisible to the listing pass: conversations that live only
|
||||
inside that project are never fetched at all. This finds the projects your
|
||||
account actually uses and prints a paste-ready line.
|
||||
"""
|
||||
cfg = _load_config_or_exit(ctx.obj["debug"])
|
||||
if not cfg.chatgpt_session_token:
|
||||
console.print("[red]CHATGPT_SESSION_TOKEN is not set — run 'ai-chat-exporter auth'.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
from src.providers.chatgpt import ChatGPTProvider
|
||||
|
||||
try:
|
||||
prov = ChatGPTProvider(
|
||||
session_token=cfg.chatgpt_session_token,
|
||||
session_token_1=cfg.chatgpt_session_token_1,
|
||||
project_ids=cfg.chatgpt_project_ids,
|
||||
)
|
||||
except ProviderError as e:
|
||||
_handle_provider_error(e, ctx.obj["debug"])
|
||||
sys.exit(1)
|
||||
|
||||
configured = list(cfg.chatgpt_project_ids)
|
||||
console.print(f"\n[bold cyan][CHATGPT][/bold cyan] {len(configured)} project ID(s) configured")
|
||||
console.print("Listing conversations…")
|
||||
|
||||
try:
|
||||
summaries = prov.fetch_all_conversations(since=None)
|
||||
except ProviderError as e:
|
||||
_handle_provider_error(e, ctx.obj["debug"])
|
||||
sys.exit(1)
|
||||
|
||||
found: dict[str, str] = {}
|
||||
missing_gizmo: list[dict] = []
|
||||
for conv in summaries:
|
||||
gizmo_id = conv.get("gizmo_id")
|
||||
if gizmo_id and str(gizmo_id).startswith("g-p-"):
|
||||
found.setdefault(gizmo_id, "")
|
||||
elif gizmo_id is None:
|
||||
missing_gizmo.append(conv)
|
||||
|
||||
if found:
|
||||
console.print(
|
||||
f" [dim]The listing names a project on {len(summaries) - len(missing_gizmo)} "
|
||||
f"conversation(s) — no detail fetches needed.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]The listing does not carry gizmo_id, so projects can only be "
|
||||
"read from conversation details.[/yellow]"
|
||||
)
|
||||
|
||||
if deep and missing_gizmo:
|
||||
console.print(
|
||||
f" Fetching detail for {len(missing_gizmo)} conversation(s) "
|
||||
f"(~{len(missing_gizmo) * cfg.request_delay / 60:.0f} min at "
|
||||
f"{cfg.request_delay}s pacing)…"
|
||||
)
|
||||
from rich.progress import (
|
||||
BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn,
|
||||
)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task("Scanning…", total=len(missing_gizmo))
|
||||
for conv in missing_gizmo:
|
||||
conv_id = conv.get("id") or conv.get("conversation_id")
|
||||
if conv_id:
|
||||
try:
|
||||
raw = prov.get_conversation(conv_id)
|
||||
gizmo_id = raw.get("gizmo_id")
|
||||
if gizmo_id and str(gizmo_id).startswith("g-p-"):
|
||||
found.setdefault(gizmo_id, "")
|
||||
except ProviderError:
|
||||
pass
|
||||
progress.advance(task)
|
||||
elif missing_gizmo and not found:
|
||||
console.print(
|
||||
f" [dim]Re-run with --deep to read {len(missing_gizmo)} conversation "
|
||||
"detail(s). Nothing to report without it.[/dim]"
|
||||
)
|
||||
|
||||
for gizmo_id in list(found) + [p for p in configured if p not in found]:
|
||||
found[gizmo_id] = prov._fetch_project_name(gizmo_id)
|
||||
|
||||
table = Table(title="ChatGPT Projects", show_header=True)
|
||||
table.add_column("Project", style="cyan")
|
||||
table.add_column("ID", style="dim")
|
||||
table.add_column("In .env")
|
||||
for gizmo_id, name in sorted(found.items(), key=lambda kv: kv[1].lower()):
|
||||
in_env = gizmo_id in configured
|
||||
table.add_row(
|
||||
name,
|
||||
gizmo_id,
|
||||
"[green]yes[/green]" if in_env else "[yellow]NO — add it[/yellow]",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
new_ids = [g for g in found if g not in configured]
|
||||
if not new_ids:
|
||||
console.print("[green]Every project found is already configured.[/green]")
|
||||
return
|
||||
|
||||
combined = configured + new_ids
|
||||
line = "CHATGPT_PROJECT_IDS=" + ",".join(combined)
|
||||
console.print(
|
||||
f"\n[bold]{len(new_ids)} project(s) missing from your config.[/bold] "
|
||||
"Conversations that live only inside them are not being exported."
|
||||
)
|
||||
console.print("\n[dim]Paste into .env:[/dim]")
|
||||
console.print(line)
|
||||
|
||||
if write:
|
||||
_set_env_key("CHATGPT_PROJECT_IDS", ",".join(combined))
|
||||
else:
|
||||
console.print("\n[dim]Or re-run with --write to update .env directly.[/dim]")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--provider",
|
||||
|
||||
Reference in New Issue
Block a user