Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
28 changes: 16 additions & 12 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
from utils.cursor_md_exporter import cursor_ide_chat_to_markdown
from services.workspace_db import (
_build_composer_id_to_workspace_id,
_collect_workspace_entries,
build_composer_id_to_workspace_id,
collect_workspace_entries,
load_bubble_map,
load_code_block_diff_map,
_open_global_db,
open_global_db,
)
from services.workspace_resolver import (
_get_workspace_display_name,
_create_project_name_to_workspace_id_map,
create_project_name_to_workspace_id_map,
lookup_workspace_display_name,
)

bp = Blueprint("export_api", __name__)
Expand All @@ -47,8 +47,12 @@ def _get_export_state() -> dict:
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
except (json.JSONDecodeError, ValueError, OSError) as e:
_logger.warning(
"Could not read export state from %s: %s",
state_path,
e,
)
return {}


Expand Down Expand Up @@ -96,15 +100,15 @@ def export_chats():
last_export_ms = to_epoch_ms(ts_str)

# ── Workspace scanning via service layer ──────────────────────────────
workspace_entries = _collect_workspace_entries(workspace_path)
composer_id_to_ws = _build_composer_id_to_workspace_id(workspace_path, workspace_entries)
project_name_map = _create_project_name_to_workspace_id_map(workspace_entries)
workspace_entries = collect_workspace_entries(workspace_path)
composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries)
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)

# Build display-name and slug maps
ws_id_to_slug: dict[str, str] = {}
ws_id_to_display_name: dict[str, str] = {}
for e in workspace_entries:
display = _get_workspace_display_name(workspace_path, e["name"])
display = lookup_workspace_display_name(workspace_path, e["name"])
if display != e["name"]:
ws_id_to_display_name[e["name"]] = display
ws_id_to_slug[e["name"]] = slug(display)
Expand All @@ -114,7 +118,7 @@ def export_chats():
rules = current_app.config.get("EXCLUSION_RULES") or []

# ── Database reading via service layer ────────────────────────────────
with _open_global_db(workspace_path) as (global_db, global_db_path):
with open_global_db(workspace_path) as (global_db, _):
if global_db is None:
return jsonify({"error": "Cursor global storage not found"}), 404

Expand Down
31 changes: 17 additions & 14 deletions api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,10 @@
)
from utils.workspace_descriptor import read_json_file
from services.workspace_resolver import (
_infer_workspace_name_from_context,
# Re-exported for back-compat with existing tests that import from api.workspaces
# directly (test_invalid_workspace_aliases, test_workspace_assignment_fallback,
# test_workspace_name_inference, test_models_wired_at_read_sites).
# Production callers should import from services.workspace_resolver instead.
_determine_project_for_conversation, # noqa: F401
_infer_invalid_workspace_aliases, # noqa: F401
_get_workspace_display_name, # noqa: F401
infer_workspace_name_from_context,
lookup_workspace_display_name,
)
from services.cli_tabs import _get_cli_workspace_tabs
from services.cli_tabs import get_cli_workspace_tabs
from services.workspace_listing import list_workspace_projects
from services.workspace_tabs import assemble_workspace_tabs

Expand All @@ -46,6 +40,11 @@
_logger = logging.getLogger(__name__)


def _exclusion_rules() -> list:
"""Return loaded exclusion rules from app config (empty list when unset)."""
return current_app.config.get("EXCLUSION_RULES") or []


# ---------------------------------------------------------------------------
# GET /api/workspaces
# ---------------------------------------------------------------------------
Expand All @@ -54,7 +53,7 @@
def list_workspaces():
try:
workspace_path = resolve_workspace_path()
rules = current_app.config.get("EXCLUSION_RULES") or []
rules = _exclusion_rules()
projects, warnings = list_workspace_projects(workspace_path, rules)
payload: dict = {"projects": projects}
if warnings:
Expand Down Expand Up @@ -121,12 +120,12 @@ def get_workspace(workspace_id):
if derived_name:
workspace_name = derived_name
elif workspace_name == workspace_id:
inferred = _infer_workspace_name_from_context(workspace_path, workspace_id)
inferred = infer_workspace_name_from_context(workspace_path, workspace_id)
if inferred:
workspace_name = inferred
except Exception as e:
warn_workspace_json_read(_logger, workspace_id, e)
inferred = _infer_workspace_name_from_context(workspace_path, workspace_id)
inferred = infer_workspace_name_from_context(workspace_path, workspace_id)
if inferred:
workspace_name = inferred

Expand All @@ -150,10 +149,14 @@ def get_workspace(workspace_id):
@bp.route("/api/workspaces/<workspace_id>/tabs")
def get_workspace_tabs(workspace_id):
if workspace_id.startswith("cli:"):
return _get_cli_workspace_tabs(workspace_id)
try:
return get_cli_workspace_tabs(workspace_id)
except Exception:
_logger.exception("Failed to get CLI workspace tabs")
return jsonify({"error": "Failed to get workspace tabs"}), 500
try:
workspace_path = resolve_workspace_path()
rules = current_app.config.get("EXCLUSION_RULES") or []
rules = _exclusion_rules()
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
return jsonify(payload), status
except Exception:
Expand Down
68 changes: 35 additions & 33 deletions scripts/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
extract_text_from_bubble,
slug,
)
from utils.tool_parser import parse_tool_call # noqa: E402
from utils.workspace_path import ( # noqa: E402
get_cli_chats_path,
resolve_workspace_path,
Expand All @@ -55,26 +54,26 @@
)
from models import ExportEntry, SchemaError # noqa: E402
from services.workspace_db import ( # noqa: E402
_build_composer_id_to_workspace_id,
_collect_invalid_workspace_ids,
_collect_workspace_entries,
build_composer_id_to_workspace_id,
collect_invalid_workspace_ids,
collect_workspace_entries,
load_bubble_map,
load_code_block_diff_map,
load_project_layouts_map,
_open_global_db,
open_global_db,
)
from services.workspace_resolver import ( # noqa: E402
_determine_project_for_conversation,
_get_workspace_display_name,
_infer_invalid_workspace_aliases,
_create_project_name_to_workspace_id_map,
_create_workspace_path_to_id_map,
create_project_name_to_workspace_id_map,
create_workspace_path_to_id_map,
determine_project_for_conversation,
infer_invalid_workspace_aliases,
lookup_workspace_display_name,
)

_logger = logging.getLogger(__name__)


def _configure_cli_logging() -> None:
def configure_cli_logging() -> None:
"""Route log records to stderr so stdout stays for export progress lines."""
root = logging.getLogger()
if root.handlers:
Expand All @@ -86,15 +85,15 @@ def _configure_cli_logging() -> None:
)


def _json_dump_safe(value) -> str:
def json_dump_safe(value) -> str:
"""Best-effort JSON serialization for exclusion matching."""
try:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
except Exception:
return str(value) if value is not None else ""


def _load_manifest_entries(manifest_path: str) -> dict:
def load_manifest_entries(manifest_path: str) -> dict:
"""Load manifest entries keyed by log_id from a JSONL file."""
existing: dict = {}
if not os.path.isfile(manifest_path):
Expand All @@ -117,7 +116,7 @@ def _load_manifest_entries(manifest_path: str) -> dict:
return existing


def _write_manifest_entries(manifest_path: str, entries_by_id: dict):
def write_manifest_entries(manifest_path: str, entries_by_id: dict):
"""Write manifest entries to JSONL."""
os.makedirs(os.path.dirname(manifest_path), exist_ok=True)
with open(manifest_path, "w", encoding="utf-8") as f:
Expand Down Expand Up @@ -177,7 +176,7 @@ def parse_args():


def main():
_configure_cli_logging()
configure_cli_logging()
opts = parse_args()
since = opts["since"]
out_dir = os.path.abspath(opts["out_dir"])
Expand All @@ -197,15 +196,18 @@ def main():
ts = st.get("lastExportTime")
if ts:
last_export = int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
pass
except Exception as e:
_logger.warning(
"Could not read last export timestamp; defaulting to full export: %s",
e,
)

# ── Workspace scanning via service layer ──────────────────────────────────
workspace_entries = _collect_workspace_entries(workspace_path)
invalid_workspace_ids = _collect_invalid_workspace_ids(workspace_entries)
project_name_map = _create_project_name_to_workspace_id_map(workspace_entries)
workspace_path_map = _create_workspace_path_to_id_map(workspace_entries)
composer_id_to_ws = _build_composer_id_to_workspace_id(workspace_path, workspace_entries)
workspace_entries = collect_workspace_entries(workspace_path)
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries)

# Build display-name and slug maps from workspace entries.
# Entries whose workspace.json cannot be resolved are omitted so the
Expand All @@ -214,7 +216,7 @@ def main():
workspace_id_to_display_name: dict[str, str] = {}
workspace_id_to_slug: dict[str, str] = {}
for e in workspace_entries:
display = _get_workspace_display_name(workspace_path, e["name"])
display = lookup_workspace_display_name(workspace_path, e["name"])
if display != e["name"]: # successfully resolved a human-readable name
workspace_id_to_display_name[e["name"]] = display
workspace_id_to_slug[e["name"]] = slug(display)
Expand All @@ -226,7 +228,7 @@ def main():
ide_composer_rows: list = []
invalid_workspace_aliases: dict = {}

with _open_global_db(workspace_path) as (global_db, global_db_path):
with open_global_db(workspace_path) as (global_db, global_db_path):
if global_db is None:
_logger.info(
"Cursor IDE global storage not found at %s — skipping IDE chats.",
Expand All @@ -245,7 +247,7 @@ def main():
except sqlite3.Error:
pass

invalid_workspace_aliases = _infer_invalid_workspace_aliases(
invalid_workspace_aliases = infer_invalid_workspace_aliases(
composer_rows=ide_composer_rows,
project_layouts_map=project_layouts_map,
project_name_map=project_name_map,
Expand Down Expand Up @@ -278,7 +280,7 @@ def main():
continue

# Workspace assignment via service layer
pid = _determine_project_for_conversation(
pid = determine_project_for_conversation(
cd, composer_id, project_layouts_map,
project_name_map, workspace_path_map,
workspace_entries, bubble_map, composer_id_to_ws, invalid_workspace_ids,
Expand Down Expand Up @@ -307,9 +309,9 @@ def main():
text = extract_text_from_bubble(b)
if text:
bubble_texts.append(text)
bubble_meta_parts.append(_json_dump_safe(b))
bubble_meta_parts.append(json_dump_safe(b))

code_diff_parts = [_json_dump_safe(d) for d in code_block_diff_map.get(composer_id, [])]
code_diff_parts = [json_dump_safe(d) for d in code_block_diff_map.get(composer_id, [])]
searchable = build_searchable_text(
project_name=ws_display_name,
chat_title=title,
Expand All @@ -320,7 +322,7 @@ def main():
bubble_texts
+ bubble_meta_parts
+ code_diff_parts
+ [_json_dump_safe(model_config), _json_dump_safe(cd)]
+ [json_dump_safe(model_config), json_dump_safe(cd)]
)
if p
),
Expand Down Expand Up @@ -484,7 +486,7 @@ def main():
f.write(entry["content"])

manifest_path = os.path.join(out_dir, "manifest.jsonl")
existing = _load_manifest_entries(manifest_path)
existing = load_manifest_entries(manifest_path)
for entry in exported:
existing[entry["id"]] = {
"log_id": entry["id"],
Expand All @@ -494,10 +496,10 @@ def main():
"updated_at": datetime.fromtimestamp(entry["updatedAt"] / 1000).isoformat() if entry["updatedAt"] else datetime.now().isoformat(),
}
if existing:
_write_manifest_entries(manifest_path, existing)
write_manifest_entries(manifest_path, existing)

global_manifest_path = os.path.join(state_dir, "manifest.jsonl")
global_existing = _load_manifest_entries(global_manifest_path)
global_existing = load_manifest_entries(global_manifest_path)
for entry in exported:
global_existing[entry["id"]] = {
"log_id": entry["id"],
Expand All @@ -507,7 +509,7 @@ def main():
"updated_at": datetime.fromtimestamp(entry["updatedAt"] / 1000).isoformat() if entry["updatedAt"] else datetime.now().isoformat(),
}
if global_existing:
_write_manifest_entries(global_manifest_path, global_existing)
write_manifest_entries(global_manifest_path, global_existing)
print(f"Exported {count} chat(s) to {out_dir}")

state = {
Expand Down
14 changes: 12 additions & 2 deletions services/cli_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,18 @@
from utils.workspace_path import get_cli_chats_path


def _get_cli_workspace_tabs(workspace_id: str):
"""Return tabs for a Cursor CLI project (workspace_id starts with "cli:")."""
def get_cli_workspace_tabs(workspace_id: str):
"""Return Flask JSON response with tabs for a Cursor CLI project.

Args:
workspace_id: Workspace id with ``cli:`` prefix (e.g. ``cli:proj-1``).

Returns:
``flask.Response | tuple[flask.Response, int]`` suitable for a Flask route
handler. Success returns ``jsonify({"tabs": ...})`` (plain ``Response``,
status 200). Errors return ``(jsonify({"error": ...}), status)`` with
404 when the project is missing or 500 on unexpected failure.
"""
try:
project_id = workspace_id[4:]
cli_projects = list_cli_projects(get_cli_chats_path())
Expand Down
Loading
Loading