Skip to content

Commit 183a4a8

Browse files
committed
feat: initial implementation
1 parent 08667a5 commit 183a4a8

6 files changed

Lines changed: 309 additions & 56 deletions

File tree

api/export_api.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,15 @@
2222
from utils.text_extract import extract_text_from_bubble, slug
2323
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
2424
from utils.cursor_md_exporter import cursor_ide_chat_to_markdown
25+
from services.workspace_context import (
26+
enrich_workspace_context_from_global_db,
27+
resolve_workspace_context,
28+
)
2529
from services.workspace_db import (
26-
build_composer_id_to_workspace_id,
27-
collect_workspace_entries,
28-
load_bubble_map,
2930
load_code_block_diff_map,
3031
open_global_db,
3132
)
32-
from services.workspace_resolver import (
33-
create_project_name_to_workspace_id_map,
34-
lookup_workspace_display_name,
35-
)
33+
from services.workspace_resolver import lookup_workspace_display_name
3634

3735
bp = Blueprint("export_api", __name__)
3836
_logger = logging.getLogger(__name__)
@@ -102,9 +100,13 @@ def export_chats():
102100
last_export_ms = to_epoch_ms(ts_str)
103101

104102
# ── Workspace scanning via service layer ──────────────────────────────
105-
workspace_entries = collect_workspace_entries(workspace_path)
106-
composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries)
107-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
103+
ctx = resolve_workspace_context(
104+
workspace_path,
105+
include_invalid_workspace_ids=False,
106+
include_workspace_path_map=False,
107+
)
108+
workspace_entries = ctx.workspace_entries
109+
composer_id_to_ws = ctx.composer_id_to_workspace_id
108110

109111
# Build display-name and slug maps
110112
ws_id_to_slug: dict[str, str] = {}
@@ -124,7 +126,10 @@ def export_chats():
124126
if global_db is None:
125127
return jsonify({"error": "Cursor global storage not found"}), 404
126128

127-
bubble_map = load_bubble_map(global_db)
129+
ctx = enrich_workspace_context_from_global_db(
130+
ctx, global_db, populate_bubble_map=True,
131+
)
132+
bubble_map = ctx.bubble_map
128133
code_block_diff_map = load_code_block_diff_map(global_db)
129134

130135
try:

scripts/export.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,15 @@
5353
cursor_ide_chat_to_markdown,
5454
)
5555
from models import ExportEntry, SchemaError # noqa: E402
56+
from services.workspace_context import ( # noqa: E402
57+
enrich_workspace_context_from_global_db,
58+
resolve_workspace_context,
59+
)
5660
from services.workspace_db import ( # noqa: E402
57-
build_composer_id_to_workspace_id,
58-
collect_invalid_workspace_ids,
59-
collect_workspace_entries,
60-
load_bubble_map,
6161
load_code_block_diff_map,
62-
load_project_layouts_map,
6362
open_global_db,
6463
)
6564
from services.workspace_resolver import ( # noqa: E402
66-
create_project_name_to_workspace_id_map,
67-
create_workspace_path_to_id_map,
6865
determine_project_for_conversation,
6966
infer_invalid_workspace_aliases,
7067
lookup_workspace_display_name,
@@ -203,11 +200,12 @@ def main():
203200
)
204201

205202
# ── Workspace scanning via service layer ──────────────────────────────────
206-
workspace_entries = collect_workspace_entries(workspace_path)
207-
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
208-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
209-
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
210-
composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries)
203+
ctx = resolve_workspace_context(workspace_path)
204+
workspace_entries = ctx.workspace_entries
205+
invalid_workspace_ids = ctx.invalid_workspace_ids
206+
project_name_map = ctx.project_name_to_workspace_id
207+
workspace_path_map = ctx.workspace_path_to_id
208+
composer_id_to_ws = ctx.composer_id_to_workspace_id
211209

212210
# Build display-name and slug maps from workspace entries.
213211
# Entries whose workspace.json cannot be resolved are omitted so the
@@ -235,8 +233,14 @@ def main():
235233
global_db_path,
236234
)
237235
else:
238-
project_layouts_map = load_project_layouts_map(global_db)
239-
bubble_map = load_bubble_map(global_db)
236+
ctx = enrich_workspace_context_from_global_db(
237+
ctx,
238+
global_db,
239+
populate_project_layouts=True,
240+
populate_bubble_map=True,
241+
)
242+
project_layouts_map = ctx.project_layouts_map
243+
bubble_map = ctx.bubble_map
240244
code_block_diff_map = load_code_block_diff_map(global_db)
241245

242246
try:

services/workspace_context.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Workspace determination ceremony — single orchestrator for shared maps."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, replace
6+
from typing import TYPE_CHECKING
7+
8+
from services.workspace_db import (
9+
build_composer_id_to_workspace_id,
10+
build_composer_id_to_workspace_id_cached,
11+
collect_invalid_workspace_ids,
12+
collect_workspace_entries,
13+
load_bubble_map,
14+
load_project_layouts_map,
15+
)
16+
from services.workspace_resolver import (
17+
create_project_name_to_workspace_id_map,
18+
create_workspace_path_to_id_map,
19+
)
20+
21+
if TYPE_CHECKING:
22+
import sqlite3
23+
24+
25+
@dataclass(frozen=True)
26+
class WorkspaceContext:
27+
"""Precomputed workspace-resolution maps for conversation assignment."""
28+
29+
workspace_path: str
30+
workspace_entries: list[dict]
31+
invalid_workspace_ids: set[str]
32+
composer_id_to_workspace_id: dict[str, str]
33+
project_name_to_workspace_id: dict[str, str]
34+
workspace_path_to_id: dict[str, str]
35+
project_layouts_map: dict[str, list]
36+
bubble_map: dict[str, dict]
37+
38+
39+
def resolve_workspace_context(
40+
workspace_path: str,
41+
*,
42+
workspace_entries: list[dict] | None = None,
43+
rules: list | None = None,
44+
nocache: bool = False,
45+
use_composer_cache: bool = False,
46+
include_invalid_workspace_ids: bool = True,
47+
include_workspace_path_map: bool = True,
48+
global_db: sqlite3.Connection | None = None,
49+
populate_project_layouts: bool = False,
50+
populate_bubble_map: bool = False,
51+
) -> WorkspaceContext:
52+
"""Run the workspace-determination ceremony and return a typed context.
53+
54+
Always resolves ``workspace_entries`` (when not supplied), composer and
55+
project-name maps. Optional pieces are controlled by flags so lightweight
56+
consumers (e.g. HTTP export) can omit unused maps.
57+
58+
Args:
59+
workspace_path: Cursor ``workspaceStorage`` root.
60+
workspace_entries: Pre-collected entries; when ``None``, scanned from disk.
61+
rules: Exclusion rules; required when ``use_composer_cache`` is ``True``.
62+
nocache: Skip the mtime-keyed composer-map disk cache.
63+
use_composer_cache: Use :func:`build_composer_id_to_workspace_id_cached`.
64+
include_invalid_workspace_ids: When ``False``, ``invalid_workspace_ids`` is empty.
65+
include_workspace_path_map: When ``False``, ``workspace_path_to_id`` is empty.
66+
global_db: Open global ``state.vscdb`` connection for optional KV loads.
67+
populate_project_layouts: Populate ``project_layouts_map`` from *global_db*.
68+
populate_bubble_map: Populate ``bubble_map`` from *global_db*.
69+
70+
Returns:
71+
:class:`WorkspaceContext` with all requested maps populated.
72+
"""
73+
entries = (
74+
workspace_entries
75+
if workspace_entries is not None
76+
else collect_workspace_entries(workspace_path)
77+
)
78+
invalid_ids = (
79+
collect_invalid_workspace_ids(entries)
80+
if include_invalid_workspace_ids
81+
else set()
82+
)
83+
project_name_map = create_project_name_to_workspace_id_map(entries)
84+
workspace_path_map = (
85+
create_workspace_path_to_id_map(entries)
86+
if include_workspace_path_map
87+
else {}
88+
)
89+
if use_composer_cache:
90+
if rules is None:
91+
raise ValueError("rules is required when use_composer_cache=True")
92+
composer_id_to_ws = build_composer_id_to_workspace_id_cached(
93+
workspace_path, entries, rules, nocache=nocache,
94+
)
95+
else:
96+
composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, entries)
97+
98+
project_layouts: dict[str, list] = {}
99+
bubble_map: dict[str, dict] = {}
100+
if global_db is not None:
101+
if populate_project_layouts:
102+
project_layouts = load_project_layouts_map(global_db)
103+
if populate_bubble_map:
104+
bubble_map = load_bubble_map(global_db)
105+
106+
return WorkspaceContext(
107+
workspace_path=workspace_path,
108+
workspace_entries=entries,
109+
invalid_workspace_ids=invalid_ids,
110+
composer_id_to_workspace_id=composer_id_to_ws,
111+
project_name_to_workspace_id=project_name_map,
112+
workspace_path_to_id=workspace_path_map,
113+
project_layouts_map=project_layouts,
114+
bubble_map=bubble_map,
115+
)
116+
117+
118+
def enrich_workspace_context_from_global_db(
119+
ctx: WorkspaceContext,
120+
global_db: sqlite3.Connection,
121+
*,
122+
populate_project_layouts: bool = False,
123+
populate_bubble_map: bool = False,
124+
) -> WorkspaceContext:
125+
"""Return *ctx* with global KV maps loaded from an open global DB connection."""
126+
updates: dict = {}
127+
if populate_project_layouts:
128+
updates["project_layouts_map"] = load_project_layouts_map(global_db)
129+
if populate_bubble_map:
130+
updates["bubble_map"] = load_bubble_map(global_db)
131+
if not updates:
132+
return ctx
133+
return replace(ctx, **updates)

services/workspace_listing.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@
2424
nocache_enabled,
2525
set_cached_projects,
2626
)
27+
from services.workspace_context import resolve_workspace_context
2728
from services.workspace_db import (
2829
COMPOSER_ROWS_WITH_HEADERS_SQL,
29-
build_composer_id_to_workspace_id_cached,
30-
collect_invalid_workspace_ids,
3130
collect_workspace_entries,
3231
global_storage_db_path,
3332
load_project_layouts_for_composer,
@@ -36,8 +35,6 @@
3635
)
3736
from utils.workspace_path import get_cli_chats_path
3837
from services.workspace_resolver import (
39-
create_project_name_to_workspace_id_map,
40-
create_workspace_path_to_id_map,
4138
determine_project_for_conversation,
4239
infer_invalid_workspace_aliases,
4340
infer_workspace_name_from_context,
@@ -125,13 +122,17 @@ def _build_workspace_projects_uncached(
125122
nocache: bool,
126123
) -> tuple[list[dict], list[dict]]:
127124
parse_warnings = ParseWarningCollector()
128-
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
129-
130-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
131-
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
132-
composer_id_to_ws = build_composer_id_to_workspace_id_cached(
133-
workspace_path, workspace_entries, rules, nocache=nocache,
125+
ctx = resolve_workspace_context(
126+
workspace_path,
127+
workspace_entries=workspace_entries,
128+
rules=rules,
129+
nocache=nocache,
130+
use_composer_cache=True,
134131
)
132+
invalid_workspace_ids = ctx.invalid_workspace_ids
133+
project_name_map = ctx.project_name_to_workspace_id
134+
workspace_path_map = ctx.workspace_path_to_id
135+
composer_id_to_ws = ctx.composer_id_to_workspace_id
135136

136137
conversation_map: dict[str, list] = {}
137138

services/workspace_tabs.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@
2727
nocache_enabled,
2828
set_cached_tab_summaries,
2929
)
30+
from services.workspace_context import resolve_workspace_context
3031
from services.workspace_db import (
3132
COMPOSER_ROWS_WITH_HEADERS_SQL,
32-
build_composer_id_to_workspace_id_cached,
33-
collect_invalid_workspace_ids,
3433
collect_workspace_entries,
3534
global_storage_db_path,
3635
load_bubbles_for_composer,
@@ -43,8 +42,6 @@
4342
)
4443
from utils.workspace_path import get_cli_chats_path
4544
from services.workspace_resolver import (
46-
create_project_name_to_workspace_id_map,
47-
create_workspace_path_to_id_map,
4845
determine_project_for_conversation,
4946
infer_invalid_workspace_aliases,
5047
lookup_workspace_display_name,
@@ -489,12 +486,17 @@ def _build_workspace_tab_summaries_uncached(
489486
parse_warnings = ParseWarningCollector()
490487
response: dict = {"tabs": []}
491488

492-
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
493-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
494-
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
495-
composer_id_to_ws = build_composer_id_to_workspace_id_cached(
496-
workspace_path, workspace_entries, rules, nocache=nocache,
489+
ctx = resolve_workspace_context(
490+
workspace_path,
491+
workspace_entries=workspace_entries,
492+
rules=rules,
493+
nocache=nocache,
494+
use_composer_cache=True,
497495
)
496+
invalid_workspace_ids = ctx.invalid_workspace_ids
497+
project_name_map = ctx.project_name_to_workspace_id
498+
workspace_path_map = ctx.workspace_path_to_id
499+
composer_id_to_ws = ctx.composer_id_to_workspace_id
498500
matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries)
499501

500502
with open_global_db(workspace_path) as (global_db, _):
@@ -635,13 +637,16 @@ def assemble_single_tab(
635637
"""
636638
parse_warnings = ParseWarningCollector()
637639

638-
workspace_entries = collect_workspace_entries(workspace_path)
639-
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
640-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
641-
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
642-
composer_id_to_ws = build_composer_id_to_workspace_id_cached(
643-
workspace_path, workspace_entries, rules,
640+
ctx = resolve_workspace_context(
641+
workspace_path,
642+
rules=rules,
643+
use_composer_cache=True,
644644
)
645+
workspace_entries = ctx.workspace_entries
646+
invalid_workspace_ids = ctx.invalid_workspace_ids
647+
project_name_map = ctx.project_name_to_workspace_id
648+
workspace_path_map = ctx.workspace_path_to_id
649+
composer_id_to_ws = ctx.composer_id_to_workspace_id
645650
matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries)
646651

647652
with open_global_db(workspace_path) as (global_db, _):
@@ -768,13 +773,16 @@ def assemble_workspace_tabs(
768773
parse_warnings = ParseWarningCollector()
769774
response: dict = {"tabs": []}
770775

771-
workspace_entries = collect_workspace_entries(workspace_path)
772-
invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries)
773-
project_name_map = create_project_name_to_workspace_id_map(workspace_entries)
774-
workspace_path_map = create_workspace_path_to_id_map(workspace_entries)
775-
composer_id_to_ws = build_composer_id_to_workspace_id_cached(
776-
workspace_path, workspace_entries, rules,
776+
ctx = resolve_workspace_context(
777+
workspace_path,
778+
rules=rules,
779+
use_composer_cache=True,
777780
)
781+
workspace_entries = ctx.workspace_entries
782+
invalid_workspace_ids = ctx.invalid_workspace_ids
783+
project_name_map = ctx.project_name_to_workspace_id
784+
workspace_path_map = ctx.workspace_path_to_id
785+
composer_id_to_ws = ctx.composer_id_to_workspace_id
778786
matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries)
779787

780788
bubble_map: dict[str, dict] = {}

0 commit comments

Comments
 (0)