@@ -183,13 +183,51 @@ async def home(request: Request):
183183 return RedirectResponse ("/login" , status_code = 302 )
184184
185185 stats = await mcp_client .status ()
186- recent = await mcp_client .list_objects (limit = 10 )
187186 alerts = stats .get ("alerts" , []) if isinstance (stats , dict ) else []
188187
188+ # Derive projects from document project fields.
189+ all_docs = await mcp_client .list_objects (limit = 500 )
190+
191+ doc_counts : dict [str , int ] = {}
192+ last_activity : dict [str , str ] = {}
193+ for doc in all_docs :
194+ proj = doc .get ("project" , "" )
195+ if proj :
196+ doc_counts [proj ] = doc_counts .get (proj , 0 ) + 1
197+ created = doc .get ("created_at" , "" )
198+ if created > last_activity .get (proj , "" ):
199+ last_activity [proj ] = created
200+
201+ # Build project list sorted by doc count descending.
202+ projects = [
203+ {
204+ "name" : name ,
205+ "doc_count" : count ,
206+ "last_activity" : last_activity .get (name , "" )[:10 ],
207+ }
208+ for name , count in sorted (doc_counts .items (), key = lambda x : - x [1 ])
209+ ]
210+
211+ recent = all_docs [:10 ]
212+
189213 return templates .TemplateResponse (
190214 request ,
191215 "home.html" ,
192- _ctx (stats = stats , alerts = alerts , recent = recent ),
216+ _ctx (stats = stats , alerts = alerts , projects = projects , recent = recent ),
217+ )
218+
219+ @app .get ("/project/{project_name}" , response_class = HTMLResponse )
220+ async def project_detail (request : Request , project_name : str ):
221+ session = _require_auth (request )
222+ if session is None :
223+ return RedirectResponse ("/login" , status_code = 302 )
224+
225+ docs = await mcp_client .list_objects (project = project_name , limit = 50 )
226+
227+ return templates .TemplateResponse (
228+ request ,
229+ "project.html" ,
230+ _ctx (project_name = project_name , documents = docs ),
193231 )
194232
195233 @app .get ("/documents" , response_class = HTMLResponse )
@@ -217,6 +255,10 @@ async def documents_page(
217255 limit = 50 ,
218256 )
219257
258+ # Get distinct project names for the dropdown.
259+ all_docs = await mcp_client .list_objects (limit = 500 )
260+ project_names = sorted ({d .get ("project" , "" ) for d in all_docs } - {"" })
261+
220262 return templates .TemplateResponse (
221263 request ,
222264 "documents.html" ,
@@ -225,6 +267,7 @@ async def documents_page(
225267 query = q ,
226268 doc_type = doc_type ,
227269 project = project ,
270+ project_names = project_names ,
228271 ),
229272 )
230273
@@ -241,12 +284,111 @@ async def document_detail(request: Request, obj_id: str):
241284 # Access tracking happens server-side inside cortex_read.
242285 return templates .TemplateResponse (request , "detail.html" , _ctx (doc = doc ))
243286
287+ @app .get ("/explore" , response_class = HTMLResponse )
288+ async def explore_page (
289+ request : Request ,
290+ topic : str = "" ,
291+ entity_type : str = "" ,
292+ ):
293+ session = _require_auth (request )
294+ if session is None :
295+ return RedirectResponse ("/login" , status_code = 302 )
296+
297+ dossier = None
298+ connections : list [dict [str , Any ]] = []
299+
300+ if topic :
301+ # Fetch dossier for the topic.
302+ try :
303+ dossier = await mcp_client .dossier (topic )
304+ except MCPClientError :
305+ dossier = None
306+
307+ # If dossier found objects, resolve relationships for the
308+ # first object to show how it connects.
309+ if dossier and dossier .get ("objects" ):
310+ first_id = dossier ["objects" ][0 ]["id" ]
311+ try :
312+ graph_info = await mcp_client .graph (obj_id = first_id )
313+ rels = graph_info .get ("relationships" , [])
314+ obj_lookup = {
315+ o ["id" ]: o for o in dossier .get ("objects" , [])
316+ }
317+ for rel in rels :
318+ other = obj_lookup .get (rel ["other_id" ])
319+ if other :
320+ connections .append ({
321+ "direction" : rel ["direction" ],
322+ "rel_type" : rel ["rel_type" ],
323+ "other_id" : rel ["other_id" ],
324+ "other_title" : other .get ("title" , rel ["other_id" ][:8 ]),
325+ "other_type" : other .get ("type" , "" ),
326+ })
327+ except MCPClientError :
328+ pass
329+
330+ # Always fetch entities for the browse section.
331+ entities = await mcp_client .list_entities (entity_type = entity_type )
332+
333+ # Compute connection counts per entity from graph data.
334+ graph_data = await mcp_client .graph_data (limit = 500 )
335+ edge_counts : dict [str , int ] = {}
336+ node_lookup : dict [str , str ] = {} # id -> label
337+ for node in graph_data .get ("nodes" , []):
338+ d = node .get ("data" , {})
339+ node_lookup [d .get ("id" , "" )] = d .get ("label" , "" )
340+ for edge in graph_data .get ("edges" , []):
341+ d = edge .get ("data" , {})
342+ src = node_lookup .get (d .get ("source" , "" ), "" )
343+ tgt = node_lookup .get (d .get ("target" , "" ), "" )
344+ if src :
345+ edge_counts [src ] = edge_counts .get (src , 0 ) + 1
346+ if tgt :
347+ edge_counts [tgt ] = edge_counts .get (tgt , 0 ) + 1
348+
349+ # Attach connection count and size tier to each entity.
350+ max_count = max ((edge_counts .get (e .get ("name" , "" ), 0 ) for e in entities ), default = 1 ) or 1
351+ for ent in entities :
352+ count = edge_counts .get (ent .get ("name" , "" ), 0 )
353+ ent ["connection_count" ] = count
354+ # Size tiers: sm (0-20%), md (20-50%), lg (50-80%), xl (80%+)
355+ ratio = count / max_count if max_count else 0
356+ if ratio >= 0.8 :
357+ ent ["size" ] = "xl"
358+ elif ratio >= 0.5 :
359+ ent ["size" ] = "lg"
360+ elif ratio >= 0.2 :
361+ ent ["size" ] = "md"
362+ else :
363+ ent ["size" ] = "sm"
364+
365+ # Sort entities by connection count descending.
366+ entities .sort (key = lambda e : e .get ("connection_count" , 0 ), reverse = True )
367+
368+ return templates .TemplateResponse (
369+ request ,
370+ "explore.html" ,
371+ _ctx (
372+ topic = topic ,
373+ dossier = dossier ,
374+ connections = connections ,
375+ entities = entities ,
376+ entity_type = entity_type ,
377+ ),
378+ )
379+
244380 @app .get ("/graph" , response_class = HTMLResponse )
245381 async def graph_page (request : Request ):
246382 session = _require_auth (request )
247383 if session is None :
248384 return RedirectResponse ("/login" , status_code = 302 )
249- return templates .TemplateResponse (request , "graph.html" )
385+
386+ all_docs = await mcp_client .list_objects (limit = 500 )
387+ project_names = sorted ({d .get ("project" , "" ) for d in all_docs } - {"" })
388+
389+ return templates .TemplateResponse (
390+ request , "graph.html" , _ctx (project_names = project_names )
391+ )
250392
251393 @app .get ("/entities" , response_class = HTMLResponse )
252394 async def entities_page (request : Request , entity_type : str = "" ):
@@ -301,17 +443,119 @@ async def query_trail(request: Request):
301443 return templates .TemplateResponse (request , "trail.html" , _ctx (logs = logs ))
302444
303445 @app .get ("/settings" , response_class = HTMLResponse )
304- async def settings_page (request : Request ):
446+ async def settings_page (request : Request , msg : str = "" , msg_type : str = "info" ):
305447 session = _require_auth (request )
306448 if session is None :
307449 return RedirectResponse ("/login" , status_code = 302 )
308450
309451 return templates .TemplateResponse (
310452 request ,
311453 "settings.html" ,
312- _ctx (config = config , weights = {}),
454+ _ctx (config = config , weights = {}, msg = msg , msg_type = msg_type ),
313455 )
314456
457+ @app .post ("/settings/import" )
458+ async def settings_import (request : Request , vault_path : str = Form (...)):
459+ """Import an Obsidian vault into Cortex."""
460+ session = _require_auth (request )
461+ if session is None :
462+ return RedirectResponse ("/login" , status_code = 302 )
463+
464+ from pathlib import Path as _Path
465+
466+ vault = _Path (vault_path ).expanduser ().resolve ()
467+ if not vault .is_dir ():
468+ return RedirectResponse (
469+ f"/settings?msg=Directory not found: { vault_path } &msg_type=danger" ,
470+ status_code = 302 ,
471+ )
472+
473+ try :
474+ from cortex .db .store import Store
475+ from cortex .pipeline .importer import ObsidianImporter
476+
477+ store = Store (config .data_dir )
478+ importer = ObsidianImporter (store , pipeline = None )
479+ result = importer .run (vault )
480+ store .close ()
481+
482+ imported = result .get ("imported" , 0 )
483+ skipped = result .get ("skipped" , 0 )
484+ failed = result .get ("failed" , 0 )
485+ wiki = result .get ("wiki_links_created" , 0 )
486+ msg = (
487+ f"Import complete: { imported } imported, { skipped } skipped, "
488+ f"{ failed } failed, { wiki } wiki-links"
489+ )
490+ return RedirectResponse (
491+ f"/settings?msg={ msg } &msg_type=info" , status_code = 302 ,
492+ )
493+ except Exception as e :
494+ return RedirectResponse (
495+ f"/settings?msg=Import error: { e } &msg_type=danger" ,
496+ status_code = 302 ,
497+ )
498+
499+ @app .post ("/settings/export" )
500+ async def settings_export (request : Request , export_path : str = Form (...)):
501+ """Export all Cortex documents as an Obsidian-compatible vault."""
502+ session = _require_auth (request )
503+ if session is None :
504+ return RedirectResponse ("/login" , status_code = 302 )
505+
506+ from pathlib import Path as _Path
507+
508+ target = _Path (export_path ).expanduser ().resolve ()
509+ try :
510+ target .mkdir (parents = True , exist_ok = True )
511+ except OSError as e :
512+ return RedirectResponse (
513+ f"/settings?msg=Cannot create directory: { e } &msg_type=danger" ,
514+ status_code = 302 ,
515+ )
516+
517+ try :
518+ all_docs = await mcp_client .list_objects (limit = 5000 )
519+ exported = 0
520+ for doc in all_docs :
521+ obj_id = doc .get ("id" , "" )
522+ full = await mcp_client .read (obj_id )
523+ if isinstance (full , str ) or full is None :
524+ continue
525+
526+ title = full .get ("title" , obj_id [:8 ])
527+ # Sanitize filename.
528+ safe_name = "" .join (
529+ c if c .isalnum () or c in " -_" else "_" for c in title
530+ ).strip ()[:100 ]
531+ if not safe_name :
532+ safe_name = obj_id [:8 ]
533+
534+ # Build markdown with YAML frontmatter.
535+ md = "---\n "
536+ md += f"id: { obj_id } \n "
537+ md += f"type: { full .get ('type' , '' )} \n "
538+ md += f"project: { full .get ('project' , '' )} \n "
539+ md += f"tags: { full .get ('tags' , '' )} \n "
540+ md += f"created: { full .get ('created_at' , '' )} \n "
541+ md += "---\n \n "
542+ md += f"# { title } \n \n "
543+ md += full .get ("content" , "" )
544+
545+ filepath = target / f"{ safe_name } .md"
546+ filepath .write_text (md , encoding = "utf-8" )
547+ exported += 1
548+
549+ msg = f"Exported { exported } documents to { target } "
550+ return RedirectResponse (
551+ f"/settings?msg={ msg } &msg_type=info" , status_code = 302 ,
552+ )
553+ except Exception as e :
554+ return RedirectResponse (
555+ f"/settings?msg=Export error: { e } &msg_type=danger" ,
556+ status_code = 302 ,
557+ )
558+
315559 # ─── API Endpoints (for HTMX/Cytoscape) ───────────────────────
316560
317561 @app .get ("/api/graph-data" )
0 commit comments