Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
20 changes: 10 additions & 10 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 Down Expand Up @@ -96,15 +96,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 +114,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
18 changes: 6 additions & 12 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 Down Expand Up @@ -121,12 +115,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,7 +144,7 @@ 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)
return get_cli_workspace_tabs(workspace_id)
try:
workspace_path = resolve_workspace_path()
rules = current_app.config.get("EXCLUSION_RULES") or []
Expand Down
60 changes: 30 additions & 30 deletions scripts/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,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 +86,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 +117,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 +177,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 @@ -201,11 +201,11 @@ def main():
pass

# ── 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 +214,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 +226,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 +245,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 +278,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 +307,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 +320,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 +484,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 +494,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 +507,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
53 changes: 44 additions & 9 deletions services/workspace_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

# ── Global-DB KV loaders ────────────────────────────────────────────────────
# Each function accepts an already-opened sqlite3.Connection (row_factory must
# be set to sqlite3.Row by the caller, as _open_global_db does) and returns
# be set to sqlite3.Row by the caller, as open_global_db does) and returns
# a populated dict. sqlite3.Error is caught internally so a missing or
# corrupt table cannot propagate to callers.

Expand Down Expand Up @@ -113,8 +113,16 @@ def load_code_block_diff_map(global_db) -> dict[str, list]:
return diff_map


def _collect_workspace_entries(workspace_path: str) -> list[dict]:
"""Scan workspace directory and return entries with workspace.json."""
def collect_workspace_entries(workspace_path: str) -> list[dict]:
"""Scan workspace directory and return entries with workspace.json.

Args:
workspace_path: Cursor workspace storage root (parent of per-workspace folders).

Returns:
List of dicts with keys ``name`` (folder id) and ``workspaceJsonPath``.
Returns an empty list if ``workspace_path`` is missing or unreadable.
"""
entries = []
try:
for name in os.listdir(workspace_path):
Expand All @@ -131,8 +139,15 @@ def _collect_workspace_entries(workspace_path: str) -> list[dict]:
return entries


def _collect_invalid_workspace_ids(workspace_entries: list[dict]) -> set[str]:
"""Workspace IDs whose descriptors have no resolvable folder paths."""
def collect_invalid_workspace_ids(workspace_entries: list[dict]) -> set[str]:
"""Return workspace IDs whose descriptors have no resolvable folder paths.

Args:
workspace_entries: Output of :func:`collect_workspace_entries`.

Returns:
Set of workspace folder names that cannot be mapped to a folder path.
"""
invalid: set[str] = set()
for entry in workspace_entries:
try:
Expand All @@ -149,8 +164,19 @@ def _collect_invalid_workspace_ids(workspace_entries: list[dict]) -> set[str]:
return invalid


def _build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: list) -> dict:
"""Build mapping: composerId -> workspaceId from per-workspace state.vscdb."""
def build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: list) -> dict:
"""Build mapping from composer ID to workspace folder name.

Reads ``composer.composerData`` from each workspace's ``state.vscdb``.
Skips workspaces with missing databases or malformed JSON.

Args:
workspace_path: Cursor workspace storage root.
workspace_entries: Output of :func:`collect_workspace_entries`.

Returns:
Dict mapping ``composerId`` strings to workspace folder names.
"""
mapping: dict = {}
for entry in workspace_entries:
db_path = os.path.join(workspace_path, entry["name"], "state.vscdb")
Expand Down Expand Up @@ -187,8 +213,17 @@ def _build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: l


@contextmanager
def _open_global_db(workspace_path: str):
"""Yield (conn, path) for the global-storage SQLite db (read-only); (None, path) if the file is missing."""
def open_global_db(workspace_path: str):
"""Open Cursor global storage SQLite database read-only.

Args:
workspace_path: Cursor workspace storage root.

Yields:
``(conn, path)`` where ``conn`` is a :class:`sqlite3.Connection` with
``row_factory=sqlite3.Row``, or ``None`` if the database file is missing
or cannot be opened. ``path`` is always the resolved global DB path.
"""
global_db_path = os.path.join(workspace_path, "..", "globalStorage", "state.vscdb")
global_db_path = os.path.normpath(global_db_path)
if not os.path.isfile(global_db_path):
Expand Down
Loading
Loading