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:
@@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- **`tests/test_config.py::TestSessionLimiterConfig::test_defaults` depended on the developer's `.env`.** `load_config()` calls `load_dotenv(override=False)`, which re-populated the variable the test had just deleted — so it passed only on a machine with no `.env`. The test now stubs dotenv discovery.
|
- **`tests/test_config.py::TestSessionLimiterConfig::test_defaults` depended on the developer's `.env`.** `load_config()` calls `load_dotenv(override=False)`, which re-populated the variable the test had just deleted — so it passed only on a machine with no `.env`. The test now stubs dotenv discovery.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **`projects` command — discover the project IDs your config is missing.** `CHATGPT_PROJECT_IDS` is maintained by hand, and a project missing from it is invisible to the listing pass, so its conversations are never fetched. The command reports every project your conversations belong to, marks which are absent from `.env`, and prints a paste-ready line (`--write` applies it). It reads project ids from the conversation listing when they are there and falls back to `--deep`, one detail request per conversation, when they are not.
|
||||||
- **Project attribution now reads the conversation's own `gizmo_id`.** Previously the project name came only from `CHATGPT_PROJECT_IDS`, so a conversation in a project you had not listed exported into `no-project/` even though its payload names its project. The detail response carries `gizmo_id`, so it is used as a fallback after the listing annotation and the project map — attribution stays correct without maintaining a list, and moving a chat into a new project no longer silently misfiles it. Only `g-p-` ids count: a custom GPT is not a project and must not become a folder. Each unconfigured project is reported once per run, naming the id to add, because the *listing* pass still needs `CHATGPT_PROJECT_IDS` — conversations that live only inside a project never appear in the default listing.
|
- **Project attribution now reads the conversation's own `gizmo_id`.** Previously the project name came only from `CHATGPT_PROJECT_IDS`, so a conversation in a project you had not listed exported into `no-project/` even though its payload names its project. The detail response carries `gizmo_id`, so it is used as a fallback after the listing annotation and the project map — attribution stays correct without maintaining a list, and moving a chat into a new project no longer silently misfiles it. Only `g-p-` ids count: a custom GPT is not a project and must not become a folder. Each unconfigured project is reported once per run, naming the id to add, because the *listing* pass still needs `CHATGPT_PROJECT_IDS` — conversations that live only inside a project never appear in the default listing.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -201,6 +201,28 @@ ChatGPT project conversations are stored separately from your main conversation
|
|||||||
|
|
||||||
### Finding your project IDs
|
### Finding your project IDs
|
||||||
|
|
||||||
|
The quickest way is to let the exporter find them:
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-chat-exporter projects
|
||||||
|
```
|
||||||
|
|
||||||
|
It lists every project your conversations actually belong to, marks which are
|
||||||
|
missing from `.env`, and prints a paste-ready `CHATGPT_PROJECT_IDS=` line
|
||||||
|
(`--write` updates `.env` for you). If your ChatGPT account does not include
|
||||||
|
the project on conversation summaries, add `--deep` and it reads each
|
||||||
|
conversation's detail instead — slower, one request per conversation, but
|
||||||
|
complete.
|
||||||
|
|
||||||
|
Why it matters: project attribution is resolved from each conversation's own
|
||||||
|
`gizmo_id`, so exports file correctly whether or not a project is configured.
|
||||||
|
But the *listing* pass still needs `CHATGPT_PROJECT_IDS` — conversations that
|
||||||
|
live only inside a project never appear in the default conversation list, so an
|
||||||
|
unlisted project's chats are never fetched at all. Every export run also names
|
||||||
|
any unconfigured project it encounters.
|
||||||
|
|
||||||
|
To find them by hand instead:
|
||||||
|
|
||||||
1. Open ChatGPT and click a Project in the left sidebar
|
1. Open ChatGPT and click a Project in the left sidebar
|
||||||
2. Look at the browser URL — it will look like:
|
2. Look at the browser URL — it will look like:
|
||||||
`https://chatgpt.com/g/g-p-68c2b2b3037c8191890036fb4ae3ed9f-my-project/project`
|
`https://chatgpt.com/g/g-p-68c2b2b3037c8191890036fb4ae3ed9f-my-project/project`
|
||||||
|
|||||||
+137
@@ -332,6 +332,143 @@ def doctor(ctx: click.Context) -> None:
|
|||||||
sys.exit(1)
|
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()
|
@cli.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--provider",
|
"--provider",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""CLI-level tests using Click's CliRunner — no live API calls required."""
|
"""CLI-level tests using Click's CliRunner — no live API calls required."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
@@ -297,3 +299,119 @@ class TestCanaryCommand:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "No web-API provider tokens" in result.output
|
assert "No web-API provider tokens" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectsCommand:
|
||||||
|
"""`projects` discovers project IDs missing from CHATGPT_PROJECT_IDS."""
|
||||||
|
|
||||||
|
def _patch_provider(self, monkeypatch, summaries, details=None, names=None):
|
||||||
|
import src.providers.chatgpt as chatgpt_mod
|
||||||
|
|
||||||
|
names = names or {}
|
||||||
|
details = details or {}
|
||||||
|
|
||||||
|
class FakeProvider:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self._project_ids = kwargs.get("project_ids") or []
|
||||||
|
|
||||||
|
def fetch_all_conversations(self, since=None):
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
def get_conversation(self, conv_id):
|
||||||
|
return details.get(conv_id, {})
|
||||||
|
|
||||||
|
def _fetch_project_name(self, gizmo_id):
|
||||||
|
return names.get(gizmo_id, gizmo_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(chatgpt_mod, "ChatGPTProvider", FakeProvider)
|
||||||
|
return FakeProvider
|
||||||
|
|
||||||
|
def _env(self, tmp_path, **extra):
|
||||||
|
# Clears the ToS gate and the first-run doctor check.
|
||||||
|
cache = Cache(tmp_path)
|
||||||
|
cache.acknowledge_tos()
|
||||||
|
cache.mark_exported("chatgpt", "dummy", {"updated_at": "2024-01-01T00:00:00Z"})
|
||||||
|
env = {
|
||||||
|
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||||
|
"CACHE_DIR": str(tmp_path),
|
||||||
|
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def test_reports_project_absent_from_config(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1", "gizmo_id": "g-p-missing"}],
|
||||||
|
names={"g-p-missing": "Tech Questions"},
|
||||||
|
)
|
||||||
|
result = CliRunner(mix_stderr=True).invoke(
|
||||||
|
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Tech Questions" in result.output
|
||||||
|
assert "g-p-missing" in result.output
|
||||||
|
assert "CHATGPT_PROJECT_IDS=g-p-missing" in result.output
|
||||||
|
|
||||||
|
def test_quiet_when_everything_is_configured(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1", "gizmo_id": "g-p-known"}],
|
||||||
|
names={"g-p-known": "Known"},
|
||||||
|
)
|
||||||
|
result = CliRunner(mix_stderr=True).invoke(
|
||||||
|
cli,
|
||||||
|
["--no-log-file", "projects"],
|
||||||
|
env=self._env(tmp_path, CHATGPT_PROJECT_IDS="g-p-known"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "already configured" in result.output
|
||||||
|
|
||||||
|
def test_custom_gpt_ids_are_ignored(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1", "gizmo_id": "g-notaproject"}],
|
||||||
|
)
|
||||||
|
result = CliRunner(mix_stderr=True).invoke(
|
||||||
|
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||||
|
)
|
||||||
|
assert "g-notaproject" not in result.output
|
||||||
|
|
||||||
|
def test_deep_reads_conversation_details(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1"}], # listing carries no gizmo_id
|
||||||
|
details={"c1": {"gizmo_id": "g-p-fromdetail"}},
|
||||||
|
names={"g-p-fromdetail": "Found Deep"},
|
||||||
|
)
|
||||||
|
result = CliRunner(mix_stderr=True).invoke(
|
||||||
|
cli, ["--no-log-file", "projects", "--deep"], env=self._env(tmp_path)
|
||||||
|
)
|
||||||
|
assert "Found Deep" in result.output
|
||||||
|
assert "CHATGPT_PROJECT_IDS=g-p-fromdetail" in result.output
|
||||||
|
|
||||||
|
def test_without_deep_says_so_rather_than_reporting_nothing(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1"}],
|
||||||
|
details={"c1": {"gizmo_id": "g-p-fromdetail"}},
|
||||||
|
)
|
||||||
|
result = CliRunner(mix_stderr=True).invoke(
|
||||||
|
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||||
|
)
|
||||||
|
assert "--deep" in result.output
|
||||||
|
|
||||||
|
def test_write_updates_env(self, tmp_path, monkeypatch):
|
||||||
|
self._patch_provider(
|
||||||
|
monkeypatch,
|
||||||
|
summaries=[{"id": "c1", "gizmo_id": "g-p-new"}],
|
||||||
|
names={"g-p-new": "New Project"},
|
||||||
|
)
|
||||||
|
runner = CliRunner(mix_stderr=True)
|
||||||
|
with runner.isolated_filesystem(temp_dir=tmp_path) as fs:
|
||||||
|
result = runner.invoke(
|
||||||
|
cli, ["--no-log-file", "projects", "--write"], env=self._env(tmp_path)
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
env_text = (Path(fs) / ".env").read_text(encoding="utf-8")
|
||||||
|
assert "CHATGPT_PROJECT_IDS=g-p-new" in env_text
|
||||||
|
|||||||
Reference in New Issue
Block a user