feat: add two-instance compare review UI (Phase 2)
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- outline_sync.py: write compare results to /tmp/compare_results.json with full doc maps for parent-chain lookup during copy operations - webui.py: /review page with remote-only, local-only, conflict tables; /review/content endpoint to fetch doc text from either instance; /review/apply endpoint for copy_to_local, copy_to_remote, keep_remote, keep_local, skip actions; "Compare Instances" button on dashboard; "Review Differences" link shown after compare completes - entrypoint.sh: include local block in generated settings.json - docker-compose.yml: pass LOCAL_OUTLINE_URL/TOKEN/HOST env vars - deploy.yml: write LOCAL_* secrets to .env file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ jobs:
|
|||||||
OUTLINE_TOKEN=${{ secrets.OUTLINE_TOKEN }}
|
OUTLINE_TOKEN=${{ secrets.OUTLINE_TOKEN }}
|
||||||
LOCAL_OUTLINE_URL=${{ secrets.LOCAL_OUTLINE_URL }}
|
LOCAL_OUTLINE_URL=${{ secrets.LOCAL_OUTLINE_URL }}
|
||||||
LOCAL_OUTLINE_TOKEN=${{ secrets.LOCAL_OUTLINE_TOKEN }}
|
LOCAL_OUTLINE_TOKEN=${{ secrets.LOCAL_OUTLINE_TOKEN }}
|
||||||
|
LOCAL_OUTLINE_HOST=${{ secrets.LOCAL_OUTLINE_HOST }}
|
||||||
TS_AUTHKEY=${{ secrets.TS_AUTHKEY }}
|
TS_AUTHKEY=${{ secrets.TS_AUTHKEY }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
- OUTLINE_TOKEN=${OUTLINE_TOKEN}
|
- OUTLINE_TOKEN=${OUTLINE_TOKEN}
|
||||||
- LOCAL_OUTLINE_URL=${LOCAL_OUTLINE_URL:-}
|
- LOCAL_OUTLINE_URL=${LOCAL_OUTLINE_URL:-}
|
||||||
- LOCAL_OUTLINE_TOKEN=${LOCAL_OUTLINE_TOKEN:-}
|
- LOCAL_OUTLINE_TOKEN=${LOCAL_OUTLINE_TOKEN:-}
|
||||||
|
- LOCAL_OUTLINE_HOST=${LOCAL_OUTLINE_HOST:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tailscale-state:
|
tailscale-state:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ cat > /work/settings.json <<EOF
|
|||||||
{
|
{
|
||||||
"source": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
|
"source": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
|
||||||
"target": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
|
"target": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
|
||||||
"local": { "url": "${LOCAL_OUTLINE_URL:-}", "token": "${LOCAL_OUTLINE_TOKEN:-}" },
|
"local": { "url": "${LOCAL_OUTLINE_URL:-}", "token": "${LOCAL_OUTLINE_TOKEN:-}", "host": "${LOCAL_OUTLINE_HOST:-}" },
|
||||||
"sync": { "allow_deletions": false }
|
"sync": { "allow_deletions": false }
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ def sanitize_name(name: str, max_len: int = 200) -> str:
|
|||||||
|
|
||||||
class OutlineSync:
|
class OutlineSync:
|
||||||
|
|
||||||
def __init__(self, base_url: str, api_token: str, vault_dir: Path):
|
def __init__(self, base_url: str, api_token: str, vault_dir: Path, host_header: str = ""):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
self.vault_dir = Path(vault_dir)
|
self.vault_dir = Path(vault_dir)
|
||||||
@@ -146,6 +146,8 @@ class OutlineSync:
|
|||||||
"Authorization": f"Bearer {self.api_token}",
|
"Authorization": f"Bearer {self.api_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
if host_header:
|
||||||
|
self.headers["Host"] = host_header
|
||||||
|
|
||||||
self._doc_cache: Dict[str, Dict] = {}
|
self._doc_cache: Dict[str, Dict] = {}
|
||||||
self.stats = {"collections": 0, "documents": 0, "errors": 0}
|
self.stats = {"collections": 0, "documents": 0, "errors": 0}
|
||||||
@@ -701,7 +703,7 @@ class OutlineSync:
|
|||||||
self._collect_docs_keyed(tree, coll["name"], out)
|
self._collect_docs_keyed(tree, coll["name"], out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def cmd_compare(self, local_url: str, local_token: str) -> bool:
|
def cmd_compare(self, local_url: str, local_token: str, local_host: str = "") -> bool:
|
||||||
"""
|
"""
|
||||||
Fetch both the remote and local Outline instances and print a diff report.
|
Fetch both the remote and local Outline instances and print a diff report.
|
||||||
No writes to either instance.
|
No writes to either instance.
|
||||||
@@ -721,7 +723,7 @@ class OutlineSync:
|
|||||||
print(f" → {len(remote_docs)} documents\n")
|
print(f" → {len(remote_docs)} documents\n")
|
||||||
|
|
||||||
print("Fetching local instance (outline-web)...")
|
print("Fetching local instance (outline-web)...")
|
||||||
local_client = OutlineSync(local_url, local_token, self.vault_dir)
|
local_client = OutlineSync(local_url, local_token, self.vault_dir, host_header=local_host)
|
||||||
if not local_client.health_check(label="local"):
|
if not local_client.health_check(label="local"):
|
||||||
print("✗ Cannot reach local Outline API — aborting.")
|
print("✗ Cannot reach local Outline API — aborting.")
|
||||||
return False
|
return False
|
||||||
@@ -791,6 +793,47 @@ class OutlineSync:
|
|||||||
print(f" ~ {key}")
|
print(f" ~ {key}")
|
||||||
print(f" remote: {r_ts} local: {l_ts}")
|
print(f" remote: {r_ts} local: {l_ts}")
|
||||||
|
|
||||||
|
# Write structured results for the review UI
|
||||||
|
results_data = {
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"remote_url": self.base_url,
|
||||||
|
"local_url": local_url,
|
||||||
|
"local_host": local_host,
|
||||||
|
"summary": {
|
||||||
|
"in_sync": in_sync,
|
||||||
|
"remote_only": remote_only,
|
||||||
|
"local_only": local_only,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"remote_only": [
|
||||||
|
{"key": k, "title": remote_docs[k]["title"],
|
||||||
|
"id": remote_docs[k]["id"], "ts": remote_docs[k]["updatedAt"]}
|
||||||
|
for k in results["remote_only"]
|
||||||
|
],
|
||||||
|
"local_only": [
|
||||||
|
{"key": k, "title": local_docs[k]["title"],
|
||||||
|
"id": local_docs[k]["id"], "ts": local_docs[k]["updatedAt"]}
|
||||||
|
for k in results["local_only"]
|
||||||
|
],
|
||||||
|
"conflicts": [
|
||||||
|
{"key": key, "title": r["title"],
|
||||||
|
"remote_id": r["id"], "remote_ts": r["updatedAt"],
|
||||||
|
"local_id": l["id"], "local_ts": l["updatedAt"]}
|
||||||
|
for key, r, l in results["conflict"]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Full doc maps for parent-chain lookup during copy
|
||||||
|
"all_remote": remote_docs,
|
||||||
|
"all_local": local_docs,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
Path("/tmp/compare_results.json").write_text(
|
||||||
|
json.dumps(results_data), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not write compare results: %s", exc)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ── Commands ──────────────────────────────────────────────────────────────
|
# ── Commands ──────────────────────────────────────────────────────────────
|
||||||
@@ -885,6 +928,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
p.add_argument("--token", help="Remote API token (overrides settings.source.token)")
|
p.add_argument("--token", help="Remote API token (overrides settings.source.token)")
|
||||||
p.add_argument("--local-url", help="Local Outline API URL (overrides settings.local.url)")
|
p.add_argument("--local-url", help="Local Outline API URL (overrides settings.local.url)")
|
||||||
p.add_argument("--local-token", help="Local API token (overrides settings.local.token)")
|
p.add_argument("--local-token", help="Local API token (overrides settings.local.token)")
|
||||||
|
p.add_argument("--local-host", help="Host header for local instance (overrides settings.local.host)")
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"-v", "--verbose",
|
"-v", "--verbose",
|
||||||
action="count",
|
action="count",
|
||||||
@@ -931,13 +975,14 @@ def main() -> None:
|
|||||||
elif args.command == "compare":
|
elif args.command == "compare":
|
||||||
local_url = args.local_url or local.get("url")
|
local_url = args.local_url or local.get("url")
|
||||||
local_token = args.local_token or local.get("token")
|
local_token = args.local_token or local.get("token")
|
||||||
|
local_host = args.local_host or local.get("host", "")
|
||||||
if not local_url or not local_token:
|
if not local_url or not local_token:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Missing local API URL or token — set local.url and local.token "
|
"Missing local API URL or token — set local.url and local.token "
|
||||||
"in settings.json, or pass --local-url / --local-token."
|
"in settings.json, or pass --local-url / --local-token."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ok = sync.cmd_compare(local_url, local_token)
|
ok = sync.cmd_compare(local_url, local_token, local_host)
|
||||||
else:
|
else:
|
||||||
ok = False
|
ok = False
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
|
|||||||
369
webui.py
369
webui.py
@@ -22,6 +22,8 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests as _requests
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
@@ -50,6 +52,18 @@ app = FastAPI(title="Outline Sync UI", docs_url=None, redoc_url=None)
|
|||||||
_jobs: dict[str, dict] = {}
|
_jobs: dict[str, dict] = {}
|
||||||
_active_job: Optional[str] = None
|
_active_job: Optional[str] = None
|
||||||
|
|
||||||
|
_compare_state: dict = {} # last compare results (loaded from /tmp/compare_results.json)
|
||||||
|
_COMPARE_RESULTS = Path("/tmp/compare_results.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_compare_state() -> dict:
|
||||||
|
try:
|
||||||
|
if _COMPARE_RESULTS.exists():
|
||||||
|
return json.loads(_COMPARE_RESULTS.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Git helpers
|
# Git helpers
|
||||||
@@ -264,10 +278,21 @@ async def run_sync_job(job_id: str, command: str) -> None:
|
|||||||
_jobs[job_id]["output"].append({"type": "log", "message": text})
|
_jobs[job_id]["output"].append({"type": "log", "message": text})
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
success = proc.returncode == 0
|
success = proc.returncode == 0
|
||||||
|
# After a successful compare, load structured results for the review UI
|
||||||
|
review_url = ""
|
||||||
|
if job["command"] == "compare" and success:
|
||||||
|
global _compare_state
|
||||||
|
_compare_state = _load_compare_state()
|
||||||
|
if _compare_state.get("summary", {}).get("in_sync") is not None:
|
||||||
|
total = sum(_compare_state["summary"].get(k, 0)
|
||||||
|
for k in ("remote_only", "local_only", "conflicts"))
|
||||||
|
if total:
|
||||||
|
review_url = "/review"
|
||||||
_jobs[job_id]["output"].append({
|
_jobs[job_id]["output"].append({
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": summary_line or ("Sync completed." if success else "Sync failed."),
|
"message": summary_line or ("Sync completed." if success else "Sync failed."),
|
||||||
|
"review_url": review_url,
|
||||||
})
|
})
|
||||||
_jobs[job_id]["status"] = "done" if success else "error"
|
_jobs[job_id]["status"] = "done" if success else "error"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -410,6 +435,14 @@ async function doSync(endpoint, label) {
|
|||||||
div.className = 'ln done';
|
div.className = 'ln done';
|
||||||
div.textContent = ev.message || 'Done.';
|
div.textContent = ev.message || 'Done.';
|
||||||
panel.appendChild(div);
|
panel.appendChild(div);
|
||||||
|
if (ev.review_url) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = ev.review_url;
|
||||||
|
a.className = 'btn btn-primary';
|
||||||
|
a.style = 'margin-top:8px;display:inline-block';
|
||||||
|
a.textContent = 'Review Differences →';
|
||||||
|
panel.appendChild(a);
|
||||||
|
}
|
||||||
panel.scrollTop = panel.scrollHeight;
|
panel.scrollTop = panel.scrollHeight;
|
||||||
src.close();
|
src.close();
|
||||||
_activeSrc = null;
|
_activeSrc = null;
|
||||||
@@ -506,6 +539,7 @@ async def dashboard():
|
|||||||
outline_tok = os.environ.get("OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
outline_tok = os.environ.get("OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||||||
local_url = os.environ.get("LOCAL_OUTLINE_URL", "") or '<span style="color:#dc3545">NOT SET</span>'
|
local_url = os.environ.get("LOCAL_OUTLINE_URL", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||||||
local_tok = os.environ.get("LOCAL_OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
local_tok = os.environ.get("LOCAL_OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||||||
|
local_host = os.environ.get("LOCAL_OUTLINE_HOST", "") or '<span style="color:#999">not set</span>'
|
||||||
|
|
||||||
body = f"""
|
body = f"""
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -533,6 +567,7 @@ async def dashboard():
|
|||||||
<tr><td>OUTLINE_TOKEN</td><td><code>{outline_tok}</code></td></tr>
|
<tr><td>OUTLINE_TOKEN</td><td><code>{outline_tok}</code></td></tr>
|
||||||
<tr><td>LOCAL_OUTLINE_URL</td><td><code>{local_url}</code></td></tr>
|
<tr><td>LOCAL_OUTLINE_URL</td><td><code>{local_url}</code></td></tr>
|
||||||
<tr><td>LOCAL_OUTLINE_TOKEN</td><td><code>{local_tok}</code></td></tr>
|
<tr><td>LOCAL_OUTLINE_TOKEN</td><td><code>{local_tok}</code></td></tr>
|
||||||
|
<tr><td>LOCAL_OUTLINE_HOST</td><td><code>{local_host}</code></td></tr>
|
||||||
<tr><td>APP_VERSION</td><td><code>{APP_VERSION}</code></td></tr>
|
<tr><td>APP_VERSION</td><td><code>{APP_VERSION}</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>"""
|
</div>"""
|
||||||
@@ -641,6 +676,340 @@ async def stream_job(job_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compare Review
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _outline_api(url: str, token: str, endpoint: str, data: dict, host: str = "") -> Optional[dict]:
|
||||||
|
"""Single Outline API call used by the review endpoints."""
|
||||||
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
if host:
|
||||||
|
headers["Host"] = host
|
||||||
|
try:
|
||||||
|
r = _requests.post(f"{url}{endpoint}", headers=headers, json=data, timeout=30)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(name: str) -> str:
|
||||||
|
import re as _re
|
||||||
|
name = _re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
||||||
|
name = _re.sub(r"\s+", " ", name).strip()
|
||||||
|
return name[:200] if name else "Untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_document(src_url, src_token, src_host, dst_url, dst_token, dst_host,
|
||||||
|
doc_id: str, match_key: str, title: str,
|
||||||
|
all_dst_docs: dict) -> tuple[bool, str]:
|
||||||
|
"""Fetch doc from src, create it on dst. Returns (ok, message)."""
|
||||||
|
# 1. Fetch content from source
|
||||||
|
full = _outline_api(src_url, src_token, "/api/documents.info", {"id": doc_id}, src_host)
|
||||||
|
if not full:
|
||||||
|
return False, "Could not fetch source document"
|
||||||
|
text = full["data"].get("text", "")
|
||||||
|
title = full["data"].get("title", title)
|
||||||
|
|
||||||
|
# 2. Find collection on destination (by sanitized name)
|
||||||
|
colls_resp = _outline_api(dst_url, dst_token, "/api/collections.list", {}, dst_host)
|
||||||
|
if not colls_resp:
|
||||||
|
return False, "Could not list collections on target"
|
||||||
|
key_parts = match_key.split("/")
|
||||||
|
coll_key = key_parts[0]
|
||||||
|
coll_map = {_sanitize(c["name"]).lower(): c for c in colls_resp.get("data", [])}
|
||||||
|
coll = coll_map.get(coll_key)
|
||||||
|
if not coll:
|
||||||
|
r = _outline_api(dst_url, dst_token, "/api/collections.create",
|
||||||
|
{"name": coll_key.title(), "private": False}, dst_host)
|
||||||
|
if not r:
|
||||||
|
return False, f"Could not create collection '{coll_key}'"
|
||||||
|
coll = r["data"]
|
||||||
|
coll_id = coll["id"]
|
||||||
|
|
||||||
|
# 3. Resolve parent on destination (best-effort)
|
||||||
|
parent_id = None
|
||||||
|
if len(key_parts) > 2:
|
||||||
|
parent_key = "/".join(key_parts[:-1])
|
||||||
|
parent_meta = all_dst_docs.get(parent_key)
|
||||||
|
if parent_meta:
|
||||||
|
parent_id = parent_meta["id"]
|
||||||
|
|
||||||
|
# 4. Create document
|
||||||
|
payload = {"title": title, "text": text, "collectionId": coll_id, "publish": True}
|
||||||
|
if parent_id:
|
||||||
|
payload["parentDocumentId"] = parent_id
|
||||||
|
r = _outline_api(dst_url, dst_token, "/api/documents.create", payload, dst_host)
|
||||||
|
if not r:
|
||||||
|
return False, "Could not create document on target"
|
||||||
|
return True, r["data"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/review", response_class=HTMLResponse)
|
||||||
|
async def review_page():
|
||||||
|
state = _compare_state or _load_compare_state()
|
||||||
|
if not state:
|
||||||
|
return HTMLResponse("<p>No compare results. Run <a href='/'>Compare Instances</a> first.</p>")
|
||||||
|
|
||||||
|
summary = state.get("summary", {})
|
||||||
|
entries = state.get("entries", {})
|
||||||
|
r_only = entries.get("remote_only", [])
|
||||||
|
l_only = entries.get("local_only", [])
|
||||||
|
conflicts= entries.get("conflicts", [])
|
||||||
|
total = summary.get("remote_only", 0) + summary.get("local_only", 0) + summary.get("conflicts", 0)
|
||||||
|
|
||||||
|
def row(entry: dict, action: str, label: str, side: str) -> str:
|
||||||
|
key = entry["key"]
|
||||||
|
title = entry.get("title", key.split("/")[-1])
|
||||||
|
doc_id = entry.get("id") or entry.get("remote_id" if side == "remote" else "local_id", "")
|
||||||
|
ts = (entry.get("ts") or entry.get("remote_ts" if side == "remote" else "local_ts", ""))[:19]
|
||||||
|
enc = base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||||||
|
return f"""
|
||||||
|
<tr id="row-{enc}">
|
||||||
|
<td style="padding:8px 4px">
|
||||||
|
<code style="font-size:.8rem;color:#666">{key}</code><br>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<div id="content-{enc}" style="display:none;margin-top:8px"></div>
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap;color:#888;font-size:.8rem">{ts}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px"
|
||||||
|
onclick="loadContent('{enc}','{doc_id}','{side}')">View</button>
|
||||||
|
<button class="btn btn-primary" style="font-size:.8rem;padding:3px 8px"
|
||||||
|
onclick="applyAction('{enc}','{action}')">✓ {label}</button>
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px;opacity:.6"
|
||||||
|
onclick="applyAction('{enc}','skip')">Skip</button>
|
||||||
|
</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
def conflict_row(entry: dict) -> str:
|
||||||
|
key = entry["key"]
|
||||||
|
enc = base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||||||
|
r_ts = entry.get("remote_ts", "")[:19]
|
||||||
|
l_ts = entry.get("local_ts", "")[:19]
|
||||||
|
return f"""
|
||||||
|
<tr id="row-{enc}">
|
||||||
|
<td style="padding:8px 4px">
|
||||||
|
<code style="font-size:.8rem;color:#666">{key}</code>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:8px">
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="font-size:.75rem;color:#888;margin-bottom:4px">remote · {r_ts}</div>
|
||||||
|
<div id="rc-{enc}" style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.8rem;white-space:pre-wrap;max-height:300px;overflow:auto">
|
||||||
|
<em style="color:#999">click View to load</em></div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="font-size:.75rem;color:#888;margin-bottom:4px">local · {l_ts}</div>
|
||||||
|
<div id="lc-{enc}" style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.8rem;white-space:pre-wrap;max-height:300px;overflow:auto">
|
||||||
|
<em style="color:#999">click View to load</em></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap;vertical-align:top">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px"
|
||||||
|
onclick="loadConflict('{enc}','{entry['remote_id']}','{entry['local_id']}')">View</button>
|
||||||
|
<button class="btn btn-primary" style="font-size:.8rem;padding:3px 8px"
|
||||||
|
onclick="applyAction('{enc}','keep_remote')">Keep Remote</button>
|
||||||
|
<button class="btn btn-success" style="font-size:.8rem;padding:3px 8px"
|
||||||
|
onclick="applyAction('{enc}','keep_local')">Keep Local</button>
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px;opacity:.6"
|
||||||
|
onclick="applyAction('{enc}','skip')">Skip</button>
|
||||||
|
</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<div class="card" style="margin-top:12px">
|
||||||
|
<h3>Remote only — {len(r_only)} documents</h3>
|
||||||
|
<p style="color:#888;font-size:.85rem">Exist on remote, missing on local. Copy to local or skip.</p>
|
||||||
|
<table style="width:100%"><thead><tr><th>Document</th><th>Updated</th><th>Action</th></tr></thead>
|
||||||
|
<tbody>{r_rows}</tbody></table>
|
||||||
|
</div>""" if r_only else ""
|
||||||
|
|
||||||
|
l_section = f"""
|
||||||
|
<div class="card" style="margin-top:12px">
|
||||||
|
<h3>Local only — {len(l_only)} documents</h3>
|
||||||
|
<p style="color:#888;font-size:.85rem">Exist on local, missing on remote. Copy to remote or skip.</p>
|
||||||
|
<table style="width:100%"><thead><tr><th>Document</th><th>Updated</th><th>Action</th></tr></thead>
|
||||||
|
<tbody>{l_rows}</tbody></table>
|
||||||
|
</div>""" if l_only else ""
|
||||||
|
|
||||||
|
c_section = f"""
|
||||||
|
<div class="card" style="margin-top:12px">
|
||||||
|
<h3>Conflicts — {len(conflicts)} documents</h3>
|
||||||
|
<p style="color:#888;font-size:.85rem">Exist on both with different content.</p>
|
||||||
|
<table style="width:100%"><thead><tr><th>Document</th><th>Action</th></tr></thead>
|
||||||
|
<tbody>{c_rows}</tbody></table>
|
||||||
|
</div>""" if conflicts else ""
|
||||||
|
|
||||||
|
ts = state.get("timestamp", "")[:19]
|
||||||
|
body = f"""
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2 style="margin:0">Review Differences</h2>
|
||||||
|
<span style="color:#888;font-size:.85rem">compared {ts}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;margin-top:12px;flex-wrap:wrap">
|
||||||
|
<span>✓ <strong>{summary.get('in_sync',0)}</strong> in sync</span>
|
||||||
|
<span>→ <strong>{summary.get('remote_only',0)}</strong> remote only</span>
|
||||||
|
<span>← <strong>{summary.get('local_only',0)}</strong> local only</span>
|
||||||
|
<span>⚡ <strong>{summary.get('conflicts',0)}</strong> conflicts</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<strong id="pending-count">{total}</strong> items to resolve
|
||||||
|
<span id="resolved-count" style="color:#28a745;margin-left:12px"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<a href="/" class="btn btn-secondary">← Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{r_section}
|
||||||
|
{l_section}
|
||||||
|
{c_section}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let resolved = 0;
|
||||||
|
const total = {total};
|
||||||
|
|
||||||
|
async function loadContent(enc, docId, instance) {{
|
||||||
|
const el = document.getElementById('content-' + enc);
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.innerHTML = '<em style="color:#999">Loading…</em>';
|
||||||
|
const r = await fetch('/review/content?doc_id=' + docId + '&instance=' + instance);
|
||||||
|
const d = await r.json();
|
||||||
|
el.innerHTML = '<pre style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.78rem;white-space:pre-wrap;max-height:300px;overflow:auto">' + escHtml(d.text || '(empty)') + '</pre>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function loadConflict(enc, remoteId, localId) {{
|
||||||
|
const [rr, lr] = await Promise.all([
|
||||||
|
fetch('/review/content?doc_id=' + remoteId + '&instance=remote').then(r=>r.json()),
|
||||||
|
fetch('/review/content?doc_id=' + localId + '&instance=local' ).then(r=>r.json()),
|
||||||
|
]);
|
||||||
|
document.getElementById('rc-' + enc).textContent = rr.text || '(empty)';
|
||||||
|
document.getElementById('lc-' + enc).textContent = lr.text || '(empty)';
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function applyAction(enc, action) {{
|
||||||
|
const row = document.getElementById('row-' + enc);
|
||||||
|
const btns = row.querySelectorAll('button');
|
||||||
|
btns.forEach(b => b.disabled = true);
|
||||||
|
const r = await fetch('/review/apply', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/json'}},
|
||||||
|
body: JSON.stringify({{enc, action}}),
|
||||||
|
}});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {{
|
||||||
|
row.style.opacity = '0.4';
|
||||||
|
row.querySelector('td:last-child').innerHTML = '<span style="color:#28a745">✓ ' + (d.message||'done') + '</span>';
|
||||||
|
resolved++;
|
||||||
|
document.getElementById('resolved-count').textContent = resolved + ' resolved';
|
||||||
|
document.getElementById('pending-count').textContent = (total - resolved) + ' remaining';
|
||||||
|
}} else {{
|
||||||
|
btns.forEach(b => b.disabled = false);
|
||||||
|
alert('Error: ' + (d.error || 'unknown'));
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function escHtml(s) {{
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}}
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
return HTMLResponse(_page(body, "Review Differences"))
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
# Phase D — Pending Changes & Diff
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user