feat: v0.2.0 — Joplin import, ChatGPT Projects, --project filter

Core features:
- Add `joplin` command: syncs exported Markdown to Joplin via local REST API
- Notebooks auto-created per provider+project (e.g. "ChatGPT - My Project")
- Idempotent: notes updated (not duplicated) on re-run; note ID tracked in manifest
- Add `--project` filter to `export` and `list` commands (substring or 'none')
- Add ChatGPT Projects support via CHATGPT_PROJECT_IDS env var

Config:
- Add JOPLIN_API_TOKEN, JOPLIN_API_URL, JOPLIN_REQUEST_TIMEOUT
- Version now read from importlib.metadata (single source of truth: pyproject.toml)
- Bump version to 0.2.0

Quality:
- Explicit Timeout handling in JoplinClient with actionable error messages
- token validation (validate_token) separate from connectivity (ping)
- Remove debug_auth.py, debug_claude.py, and untracked .har file
- Add *.har to .gitignore (may contain auth cookies/session tokens)
- Update README, CHANGELOG, FUTURE.md to reflect v0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 06:04:03 -05:00
co-authored by Claude Sonnet 4.6
parent 23d7c17255
commit 304cf4fde4
16 changed files with 1795 additions and 133 deletions
+356 -8
View File
@@ -1,5 +1,6 @@
"""CLI entry point for ai-chat-exporter."""
import importlib.metadata
import logging
import platform
import shutil
@@ -19,6 +20,7 @@ from src.providers.base import ProviderError
console = Console()
err_console = Console(stderr=True)
logger = logging.getLogger(__name__)
TOS_NOTICE = """\
⚠️ IMPORTANT — TERMS OF SERVICE NOTICE
@@ -45,7 +47,10 @@ Type 'yes' to acknowledge and continue, or Ctrl+C to exit: \
@click.group()
@click.version_option(version="0.1.0", prog_name="ai-chat-exporter")
@click.version_option(
version=importlib.metadata.version("ai-chat-exporter"),
prog_name="ai-chat-exporter",
)
@click.option("--verbose", "-v", is_flag=True, help="Enable DEBUG output to console.")
@click.option("--quiet", "-q", is_flag=True, help="Show WARNING and above only.")
@click.option("--debug", is_flag=True, help="DEBUG + full tracebacks + redacted API bodies.")
@@ -175,6 +180,39 @@ def _auth_chatgpt(os_name: str) -> None:
_write_token_to_env("CHATGPT_SESSION_TOKEN", token)
# --- ChatGPT Projects ---
console.print("\n[bold]ChatGPT Projects (optional)[/bold]")
console.print(
"Project conversations are stored separately and are not included in the\n"
"default conversation listing. To export them, you need each project's ID.\n"
)
console.print("How to find a project ID:")
console.print(" 1. Open ChatGPT and click into a Project in the left sidebar.")
console.print(" 2. Look at the browser URL — it will look like:")
console.print(" [dim]https://chatgpt.com/g/[bold]g-p-68c2b2b3037c8191890036fb4ae3ed9f[/bold]-my-project/project[/dim]")
console.print(" 3. Copy the part starting with [bold]g-p-[/bold] up to (but not including) the slug.")
console.print(" Enter multiple IDs separated by commas. Leave blank to skip.\n")
project_ids_raw = click.prompt(
"ChatGPT project IDs (comma-separated, e.g. g-p-xxx,g-p-yyy)",
default="",
show_default=False,
).strip()
if project_ids_raw:
ids = [pid.strip() for pid in project_ids_raw.split(",") if pid.strip()]
valid = [pid for pid in ids if pid.startswith("g-p-")]
invalid = [pid for pid in ids if not pid.startswith("g-p-")]
if invalid:
console.print(f"[yellow]Warning: skipping IDs that don't start with 'g-p-': {invalid}[/yellow]")
if valid:
_write_token_to_env("CHATGPT_PROJECT_IDS", ",".join(valid))
console.print(f"[green]Saved {len(valid)} project ID(s).[/green]")
else:
console.print("[yellow]No valid project IDs — skipping.[/yellow]")
else:
console.print("[dim]Skipped project IDs.[/dim]")
def _auth_claude(os_name: str) -> None:
console.print("\n[bold]─── Claude ───[/bold]")
@@ -395,6 +433,15 @@ def _print_doctor_table(checks: list[dict]) -> None:
default=None,
help="Only export conversations updated after this date (YYYY-MM-DD).",
)
@click.option(
"--project",
"project_filter",
default=None,
help=(
"Only export conversations in a matching project (case-insensitive substring). "
"Use 'none' for conversations outside any project."
),
)
@click.option("--dry-run", is_flag=True, help="Show what would be exported without writing anything.")
@click.pass_context
def export(
@@ -403,6 +450,7 @@ def export(
fmt: str,
output_dir: str | None,
since: str | None,
project_filter: str | None,
dry_run: bool,
) -> None:
"""Export new and updated conversations to Markdown or JSON.
@@ -474,6 +522,12 @@ def export(
summary[prov_name]["failed"] += len(all_convs) if "all_convs" in dir() else 0
continue
if project_filter is not None:
all_convs = _filter_by_project(all_convs, project_filter)
console.print(
f" [dim]--project filter '{project_filter}': {len(all_convs)} matching conversations.[/dim]"
)
to_export = cache.get_new_or_updated(prov_name, all_convs)
skipped = len(all_convs) - len(to_export)
summary[prov_name]["skipped"] = skipped
@@ -522,13 +576,11 @@ def export(
progress.advance(task)
except ProviderError as e:
logger = logging.getLogger(__name__)
logger.error("Failed to export conversation %s: %s", conv_id[:8], e)
summary[prov_name]["failed"] += 1
progress.advance(task)
continue
except OSError as e:
logger = logging.getLogger(__name__)
logger.error("File write failed for conversation %s: %s", conv_id[:8], e)
summary[prov_name]["failed"] += 1
progress.advance(task)
@@ -560,7 +612,21 @@ def _resolve_providers(provider: str, cfg) -> list[tuple[str, object]]:
from src.providers.claude import ClaudeProvider
if provider in ("chatgpt", "all"):
try_add("chatgpt", cfg.chatgpt_session_token, ChatGPTProvider)
if cfg.chatgpt_session_token:
try:
result.append((
"chatgpt",
ChatGPTProvider(
session_token=cfg.chatgpt_session_token,
project_ids=cfg.chatgpt_project_ids,
),
))
except ProviderError as e:
logging.getLogger(__name__).warning(
"[chatgpt] Could not initialise provider: %s", e
)
elif provider == "chatgpt" or provider == "all":
logging.getLogger(__name__).warning("[chatgpt] Skipping — token not configured.")
if provider in ("claude", "all"):
try_add("claude", cfg.claude_session_key, ClaudeProvider)
@@ -596,6 +662,44 @@ def _print_dry_run_table(prov_name, to_export, prov_instance, export_base, struc
console.print(f" [dim]{skipped} conversations already cached (would be skipped).[/dim]")
def _raw_project_name(conv: dict) -> str | None:
"""Extract the project name from a raw conversation summary dict.
Handles both ChatGPT (annotated _project_name) and Claude (project dict).
"""
# ChatGPT: annotated during fetch_all_conversations
if "_project_name" in conv:
return conv["_project_name"] or None
# Claude: project is a dict with a 'name' key, or a plain string
project = conv.get("project")
if isinstance(project, dict):
return project.get("name") or None
if isinstance(project, str):
return project or None
return None
def _filter_by_project(convs: list[dict], project_filter: str) -> list[dict]:
"""Filter conversations by project name.
project_filter='none' → keep only conversations with no project.
Otherwise → case-insensitive substring match on the project name.
"""
want_none = project_filter.lower() == "none"
needle = project_filter.lower()
result = []
for conv in convs:
name = _raw_project_name(conv)
if want_none:
if name is None:
result.append(conv)
else:
if name and needle in name.lower():
result.append(conv)
return result
def _print_export_summary(summary: dict[str, dict[str, int]]) -> None:
table = Table(title="Export Summary")
table.add_column("Provider", style="bold")
@@ -626,8 +730,17 @@ def _print_export_summary(summary: dict[str, dict[str, int]]) -> None:
default="all",
show_default=True,
)
@click.option(
"--project",
"project_filter",
default=None,
help=(
"Only list conversations in a matching project (case-insensitive substring). "
"Use 'none' for conversations outside any project."
),
)
@click.pass_context
def list_conversations(ctx: click.Context, provider: str) -> None:
def list_conversations(ctx: click.Context, provider: str, project_filter: str | None) -> None:
"""List conversations without exporting them."""
debug = ctx.obj.get("debug", False)
cfg = _load_config_or_exit(debug)
@@ -641,6 +754,9 @@ def list_conversations(ctx: click.Context, provider: str) -> None:
_handle_provider_error(e, debug)
continue
if project_filter is not None:
all_convs = _filter_by_project(all_convs, project_filter)
table = Table()
table.add_column("Title")
table.add_column("Project")
@@ -649,9 +765,7 @@ def list_conversations(ctx: click.Context, provider: str) -> None:
for conv in all_convs:
title = conv.get("title") or "Untitled"
project = conv.get("project_title") or ""
if isinstance(conv.get("project"), dict):
project = conv["project"].get("name", "")
project = _raw_project_name(conv) or ""
updated = (conv.get("updated_at") or conv.get("update_time") or "")[:10]
conv_id = (conv.get("id") or conv.get("uuid") or "")[:8]
table.add_row(title[:60], project[:30], updated, conv_id)
@@ -700,6 +814,240 @@ def cache(ctx: click.Context, show: bool, clear: bool, provider: str) -> None:
console.print("Specify --show or --clear. Use --help for options.")
# ──────────────────────────────────────────────────────────────────────────────
# joplin command
# ──────────────────────────────────────────────────────────────────────────────
@cli.command()
@click.option(
"--provider",
type=click.Choice(["chatgpt", "claude", "all"], case_sensitive=False),
default="all",
show_default=True,
help="Which provider's conversations to sync to Joplin.",
)
@click.option(
"--project",
"project_filter",
default=None,
help=(
"Only sync conversations in a matching project (case-insensitive substring). "
"Use 'none' for conversations outside any project."
),
)
@click.option("--dry-run", is_flag=True, help="Show what would be synced without sending anything to Joplin.")
@click.pass_context
def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_run: bool) -> None:
"""Sync exported conversations to Joplin as notes.
Reads the local export cache and pushes exported Markdown files to Joplin
via its local REST API. Requires Joplin desktop to be running with the
Web Clipper service enabled.
Notebooks are created automatically based on provider and project:
exports/chatgpt/my-project/ → "ChatGPT - My Project" notebook
exports/claude/no-project/ → "Claude - No Project" notebook
Re-running is safe: notes are updated (not duplicated) on subsequent runs.
Setup:
1. Open Joplin desktop.
2. Go to Tools → Options → Web Clipper.
3. Enable the Web Clipper service.
4. Copy the Authorization token.
5. Set JOPLIN_API_TOKEN=<token> in your .env file.
"""
debug = ctx.obj.get("debug", False)
cache_obj: Cache = ctx.obj["cache"]
cfg = _load_config_or_exit(debug)
if not cfg.joplin_api_token:
err_console.print(
"[red]JOPLIN_API_TOKEN is not set.[/red]\n"
" 1. Open Joplin → Tools → Options → Web Clipper.\n"
" 2. Enable the Web Clipper service.\n"
" 3. Copy the Authorization token.\n"
" 4. Add [bold]JOPLIN_API_TOKEN=<token>[/bold] to your .env file."
)
sys.exit(1)
from src.joplin import JoplinClient, JoplinError, notebook_title
client = JoplinClient(cfg.joplin_api_url, cfg.joplin_api_token)
if not dry_run:
console.print(f"[dim]Connecting to Joplin at {cfg.joplin_api_url}…[/dim]")
try:
if not client.ping():
err_console.print(
"[red]Joplin is not responding.[/red] "
"Make sure Joplin desktop is open and Web Clipper is enabled."
)
sys.exit(1)
# Ping succeeded but doesn't validate the token — check auth separately
client.validate_token()
except JoplinError as e:
err_console.print(f"[red]Joplin connection error:[/red] {e}")
sys.exit(1)
console.print("[green]Joplin connected and token validated.[/green]")
# Determine which providers to process
providers_to_sync: list[str] = []
if provider in ("chatgpt", "all"):
providers_to_sync.append("chatgpt")
if provider in ("claude", "all"):
providers_to_sync.append("claude")
summary: dict[str, dict[str, int]] = {}
for prov_name in providers_to_sync:
summary[prov_name] = {"created": 0, "updated": 0, "skipped": 0, "failed": 0}
pending = cache_obj.get_joplin_pending(prov_name)
logger.debug("[joplin] %s: %d pending before filter", prov_name, len(pending))
# Apply --project filter against the cached entry's project field
if project_filter is not None:
want_none = project_filter.lower() == "none"
needle = project_filter.lower()
filtered = []
for conv_id, entry in pending:
proj = entry.get("project") or None
if want_none:
if proj is None or proj == "no-project":
filtered.append((conv_id, entry))
else:
if proj and needle in proj.lower():
filtered.append((conv_id, entry))
logger.debug(
"[joplin] %s: --project %r filtered %d%d",
prov_name, project_filter, len(pending), len(filtered),
)
pending = filtered
if not pending:
console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan] All up to date — nothing to sync.")
continue
console.print(
f"\n[bold cyan][{prov_name.upper()}][/bold cyan] "
f"{len(pending)} conversation(s) to sync to Joplin."
)
if dry_run:
_print_joplin_dry_run_table(prov_name, pending)
continue
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
) as progress:
task = progress.add_task(f"Syncing {prov_name}", total=len(pending))
for conv_id, entry in pending:
file_path = entry.get("file_path", "")
title = entry.get("title") or "Untitled"
project = entry.get("project") or None
existing_note_id = entry.get("joplin_note_id")
action = "update" if existing_note_id else "create"
logger.debug(
"[joplin] %s %s/%s: %s (file=%s)",
action, prov_name, conv_id[:8], title[:60], file_path,
)
try:
# Read the exported Markdown file
body = Path(file_path).read_text(encoding="utf-8")
logger.debug("[joplin] Read %d chars from %s", len(body), file_path)
# Get or create the notebook
nb_title = notebook_title(prov_name, project)
notebook_id = client.get_or_create_notebook(nb_title)
if existing_note_id:
client.update_note(existing_note_id, title, body)
cache_obj.mark_joplin_synced(prov_name, conv_id, existing_note_id)
summary[prov_name]["updated"] += 1
else:
note_id = client.create_note(title, body, notebook_id)
cache_obj.mark_joplin_synced(prov_name, conv_id, note_id)
summary[prov_name]["created"] += 1
except FileNotFoundError:
logger.warning(
"[joplin] Skipping %s/%s — exported file not found: %s",
prov_name, conv_id[:8], file_path,
)
summary[prov_name]["skipped"] += 1
except JoplinError as e:
logger.error(
"[joplin] Failed to %s note for %s/%s: %s",
action, prov_name, conv_id[:8], e,
)
summary[prov_name]["failed"] += 1
except OSError as e:
logger.error(
"[joplin] File read error for %s/%s (%s): %s",
prov_name, conv_id[:8], file_path, e,
)
summary[prov_name]["failed"] += 1
finally:
progress.advance(task)
if not dry_run:
_print_joplin_summary(summary)
def _print_joplin_dry_run_table(prov_name: str, pending: list[tuple[str, dict]]) -> None:
from src.joplin import notebook_title
table = Table(title=f"[DRY RUN] {prov_name.upper()} — Would sync {len(pending)} conversation(s)")
table.add_column("Title")
table.add_column("Project")
table.add_column("Notebook")
table.add_column("Action")
for conv_id, entry in pending[:50]:
title = entry.get("title") or "Untitled"
project = entry.get("project") or "no-project"
nb = notebook_title(prov_name, entry.get("project"))
action = "update" if entry.get("joplin_note_id") else "create"
table.add_row(title[:50], project[:30], nb, action)
if len(pending) > 50:
table.add_row(f"… and {len(pending) - 50} more", "", "", "")
console.print(table)
def _print_joplin_summary(summary: dict[str, dict[str, int]]) -> None:
table = Table(title="Joplin Sync Summary")
table.add_column("Provider", style="bold")
table.add_column("Created", justify="right")
table.add_column("Updated", justify="right")
table.add_column("Skipped", justify="right")
table.add_column("Failed", justify="right")
for prov, counts in summary.items():
table.add_row(
prov.capitalize(),
str(counts["created"]),
str(counts["updated"]),
str(counts["skipped"]),
f"[red]{counts['failed']}[/red]" if counts["failed"] else "0",
)
console.print(table)
# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────