"""
r_rows = "".join(row(e, "copy_to_local", "Copy to local", "remote") for e in r_only)
l_rows = "".join(row(e, "copy_to_remote", "Copy to remote", "local") for e in l_only)
c_rows = "".join(conflict_row(e) for e in conflicts)
r_section = f"""
Remote only — {len(r_only)} documents
Exist on remote, missing on local. Copy to local or skip.
Document
Updated
Action
{r_rows}
""" if r_only else ""
l_section = f"""
Local only — {len(l_only)} documents
Exist on local, missing on remote. Copy to remote or skip.
Document
Updated
Action
{l_rows}
""" if l_only else ""
c_section = f"""
Conflicts — {len(conflicts)} documents
Exist on both with different content.
Document
Action
{c_rows}
""" if conflicts else ""
ts = state.get("timestamp", "")[:19]
body = f"""
Review Differences
compared {ts}
✓ {summary.get('in_sync',0)} in sync→ {summary.get('remote_only',0)} remote only← {summary.get('local_only',0)} local only⚡ {summary.get('conflicts',0)} conflicts
{r_section}
{l_section}
{c_section}
"""
return HTMLResponse(_page("Review Differences", body))
@app.get("/review/content")
async def review_content(doc_id: str, instance: str):
state = _compare_state or _load_compare_state()
settings = _load_settings()
if instance == "remote":
url, token, host = (settings.get("source", {}).get("url", ""),
settings.get("source", {}).get("token", ""), "")
else:
local = settings.get("local", {})
url, token, host = local.get("url", ""), local.get("token", ""), local.get("host", "")
result = await asyncio.to_thread(
_outline_api, url, token, "/api/documents.info", {"id": doc_id}, host
)
if result and "data" in result:
return JSONResponse({"text": result["data"].get("text", ""),
"title": result["data"].get("title", "")})
return JSONResponse({"error": "fetch failed", "text": ""}, status_code=500)
@app.post("/review/apply")
async def review_apply(request: Request):
body = await request.json()
enc = body.get("enc", "")
action = body.get("action", "")
settings = _load_settings()
state = _compare_state or _load_compare_state()
entries = state.get("entries", {})
if action == "skip":
return JSONResponse({"ok": True, "message": "skipped"})
# Decode key
try:
padding = "=" * (-len(enc) % 4)
key = base64.urlsafe_b64decode(enc + padding).decode()
except Exception:
return JSONResponse({"ok": False, "error": "bad key"}, status_code=400)
remote_cfg = settings.get("source", {})
local_cfg = settings.get("local", {})
r_url, r_tok, r_host = remote_cfg.get("url",""), remote_cfg.get("token",""), ""
l_url, l_tok, l_host = local_cfg.get("url",""), local_cfg.get("token",""), local_cfg.get("host","")
if action == "copy_to_local":
entry = next((e for e in entries.get("remote_only",[]) if e["key"] == key), None)
if not entry:
return JSONResponse({"ok": False, "error": "entry not found"})
ok, msg = await asyncio.to_thread(
_copy_document,
r_url, r_tok, r_host, l_url, l_tok, l_host,
entry["id"], key, entry["title"], state.get("all_local", {})
)
return JSONResponse({"ok": ok, "message": "copied to local" if ok else None, "error": msg if not ok else None})
if action == "copy_to_remote":
entry = next((e for e in entries.get("local_only",[]) if e["key"] == key), None)
if not entry:
return JSONResponse({"ok": False, "error": "entry not found"})
ok, msg = await asyncio.to_thread(
_copy_document,
l_url, l_tok, l_host, r_url, r_tok, r_host,
entry["id"], key, entry["title"], state.get("all_remote", {})
)
return JSONResponse({"ok": ok, "message": "copied to remote" if ok else None, "error": msg if not ok else None})
if action in ("keep_remote", "keep_local"):
entry = next((e for e in entries.get("conflicts",[]) if e["key"] == key), None)
if not entry:
return JSONResponse({"ok": False, "error": "entry not found"})
if action == "keep_remote":
# Push remote content to local
ok, msg = await asyncio.to_thread(
_copy_document,
r_url, r_tok, r_host, l_url, l_tok, l_host,
entry["remote_id"], key, entry["title"], state.get("all_local", {})
)
else:
# Push local content to remote
ok, msg = await asyncio.to_thread(
_copy_document,
l_url, l_tok, l_host, r_url, r_tok, r_host,
entry["local_id"], key, entry["title"], state.get("all_remote", {})
)
return JSONResponse({"ok": ok, "message": "applied" if ok else None, "error": msg if not ok else None})
return JSONResponse({"ok": False, "error": f"unknown action: {action}"}, status_code=400)
# ---------------------------------------------------------------------------
# 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"""