"""
return HTMLResponse(_page("Dashboard", body))
@app.get("/status")
async def vault_status():
s = _get_vault_status()
# Flatten last_pull / last_push to timestamps for backward-compat JSON consumers
return JSONResponse({
**s,
"last_pull": s["last_pull"]["timestamp"] if s["last_pull"] else None,
"last_push": s["last_push"]["timestamp"] if s["last_push"] else None,
})
# ---------------------------------------------------------------------------
# Phase C/E — Pull & Push
# ---------------------------------------------------------------------------
@app.post("/pull")
async def start_pull():
if _active_job is not None:
raise HTTPException(status_code=409, detail="A sync job is already running")
job_id = _new_job("pull")
return {"job_id": job_id, "stream_url": f"/stream/{job_id}"}
@app.post("/compare")
async def start_compare():
if _active_job is not None:
raise HTTPException(status_code=409, detail="A sync job is already running")
settings = _load_settings()
local = settings.get("local", {})
if not local.get("url") or not local.get("token"):
return JSONResponse(
status_code=400,
content={"detail": "Local instance not configured — set LOCAL_OUTLINE_URL and LOCAL_OUTLINE_TOKEN env vars"},
)
job_id = _new_job("compare")
return {"job_id": job_id, "stream_url": f"/stream/{job_id}"}
@app.post("/push")
async def start_push():
if _active_job is not None:
raise HTTPException(status_code=409, detail="A sync job is already running")
conflicts = _get_conflict_files()
if conflicts:
return JSONResponse(
status_code=409,
content={
"detail": "Unresolved conflicts must be resolved before pushing",
"conflicts": conflicts,
"message": "Resolve conflicts before pushing",
},
)
job_id = _new_job("push")
return {"job_id": job_id, "stream_url": f"/stream/{job_id}"}
@app.get("/stream/{job_id}")
async def stream_job(job_id: str):
if job_id not in _jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _jobs[job_id]
async def _generate():
global _active_job
# Start the job the moment the first client connects to the stream.
if job["status"] == "pending":
job["status"] = "running"
try:
await run_sync_job(job_id, job["command"])
except Exception as exc:
job["output"].append({"type": "done", "success": False, "message": str(exc)})
job["status"] = "error"
finally:
_active_job = None
# Stream all buffered output (job already ran inline above).
for event in job["output"]:
yield f"data: {json.dumps(event)}\n\n"
if event.get("type") == "done":
return
# Fallback: if job was already running when we connected, poll for new output.
cursor = 0
while True:
buf = job.get("output", [])
while cursor < len(buf):
yield f"data: {json.dumps(buf[cursor])}\n\n"
if buf[cursor].get("type") == "done":
return
cursor += 1
if job.get("status") in ("done", "error") and cursor >= len(buf):
return
await asyncio.sleep(0.05)
return StreamingResponse(
_generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ---------------------------------------------------------------------------
# Phase D — Pending Changes & Diff
# ---------------------------------------------------------------------------
@app.get("/changes")
async def changes(request: Request):
items = _get_pending_changes()
if "text/html" not in request.headers.get("accept", ""):
return JSONResponse(items)
if not items:
rows = '
No pending changes — vault is in sync.
'
else:
rows = ""
for item in items:
st = item["status"]
tag = f'{st}'
path = item["path"]
safe = re.sub(r"[^a-zA-Z0-9]", "_", path)
enc = base64.urlsafe_b64encode(path.encode()).decode().rstrip("=")
display = f'{item.get("from_path","")} → {item.get("to_path",path)}' if st == "renamed" else path
action = item.get("action", "")
if st == "deleted" and action == "skip":
act_cell = f'skip {item.get("reason","")}'
else:
act_cell = action
diff_btn = ""
if st == "modified":
diff_btn = f' preview diff'
rows += f"
{display}{diff_btn}
{tag}
{act_cell}
"
body = f"""
Pending Changes ({len(items)})
File
Status
Action
{rows}
"""
return HTMLResponse(_page("Pending Changes", body))
@app.get("/diff/{encoded_path}", response_class=HTMLResponse)
async def get_diff(encoded_path: str):
try:
padded = encoded_path + "=" * (-len(encoded_path) % 4)
path = base64.urlsafe_b64decode(padded.encode()).decode()
except Exception:
raise HTTPException(status_code=400, detail="Invalid path encoding")
if ".." in path or path.startswith("/"):
raise HTTPException(status_code=400, detail="Invalid path")
r_outline = _git("show", f"outline:{path}")
outline_text = r_outline.stdout if r_outline.returncode == 0 else ""
r_main = _git("show", f"HEAD:{path}")
if r_main.returncode != 0:
raise HTTPException(status_code=404, detail="File not found in main branch")
return HTMLResponse(_render_diff_html(outline_text, r_main.stdout, Path(path).name))
# ---------------------------------------------------------------------------
# Phase F — Conflict Resolution
# ---------------------------------------------------------------------------
class ResolveRequest(BaseModel):
file: str
accept: str
@field_validator("accept")
@classmethod
def _check_accept(cls, v: str) -> str:
if v not in ("local", "remote"):
raise ValueError("accept must be 'local' or 'remote'")
return v
@field_validator("file")
@classmethod
def _check_file(cls, v: str) -> str:
if ".." in v or v.startswith("/"):
raise ValueError("Path traversal not allowed")
return v
@app.get("/conflicts")
async def list_conflicts(request: Request):
conflict_paths = _get_conflict_files()
if "text/html" not in request.headers.get("accept", ""):
return JSONResponse([{"path": p} for p in conflict_paths])
if not conflict_paths:
inner = '
All conflicts resolved. You can now push.
Back to Dashboard'
cc_none_display, cc_list_display = "block", "none"
cards = ""
else:
cc_none_display, cc_list_display = "none", "block"
cards = ""
for path in conflict_paths:
safe = re.sub(r"[^a-zA-Z0-9]", "_", path)
enc = base64.urlsafe_b64encode(path.encode()).decode().rstrip("=")
cards += f"""
"""
return HTMLResponse(_page("Conflicts", body))
@app.post("/resolve")
async def resolve_conflict(req: ResolveRequest):
conflict_paths = _get_conflict_files()
if req.file not in conflict_paths:
raise HTTPException(status_code=404, detail=f"'{req.file}' is not in the conflict list")
side = "--ours" if req.accept == "local" else "--theirs"
r = _git("checkout", side, req.file)
if r.returncode != 0:
raise HTTPException(status_code=500, detail=f"git checkout failed: {r.stderr.strip()}")
_git("add", req.file)
_git("commit", "-m", f"resolve({req.accept}): {req.file}",
"--author", "Outline Sync UI ")
return {"ok": True, "file": req.file, "accepted": req.accept}
# ---------------------------------------------------------------------------
# Phase G — Sync History
# ---------------------------------------------------------------------------
@app.get("/history")
async def sync_history(request: Request):
entries = _parse_sync_log()
fmt = request.query_params.get("format")
if fmt == "json":
return JSONResponse(entries)
if "application/json" in request.headers.get("accept", "") \
and "text/html" not in request.headers.get("accept", ""):
return JSONResponse(entries)
if not entries:
table_body = '
No sync history yet.
'
else:
rows = ""
for e in entries:
err = "error" in e.get("status", "").lower()
st_style = ' style="color:#dc3545;font-weight:600"' if err else ""
icon = "↓ pull" if e.get("direction") == "pull" else "↑ push"
rows += f"
{e.get('timestamp','—')}
{icon}
{e.get('files','—')}
{e.get('status','—')}
"
table_body = f"
Timestamp
Direction
Files Changed
Status
{rows}
"
body = f'
Sync History
{table_body}
'
return HTMLResponse(_page("History", body))
# ---------------------------------------------------------------------------
# File browser
# ---------------------------------------------------------------------------
@app.get("/files/download")
async def download_vault():
if not VAULT_DIR.exists():
raise HTTPException(status_code=404, detail="Vault directory not found")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in sorted(VAULT_DIR.rglob("*")):
if file_path.is_file() and ".git" not in file_path.parts:
zf.write(file_path, file_path.relative_to(VAULT_DIR))
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="vault.zip"'},
)
@app.get("/files", response_class=HTMLResponse)
async def file_browser():
tree_html = _get_vault_tree_html()
# Count files (excluding .git)
file_count = 0
if VAULT_DIR.exists():
file_count = sum(
1 for p in VAULT_DIR.rglob("*")
if p.is_file() and ".git" not in p.parts
)
body = f"""