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:
+356
-8
@@ -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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user