Add sync engine, web UI, Docker setup, and tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-07 20:54:59 +01:00
parent e4c69efd12
commit b3137a8af3
27 changed files with 7133 additions and 278 deletions

230
sync.sh Executable file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env bash
#
# Outline ↔ Obsidian Sync
# Bash wrapper — delegates API/file work to Docker Python container,
# handles git operations directly on the host.
#
# Usage: ./sync.sh <command> [options]
#
# Commands (Phase 1):
# init Initialize vault from Outline content
# help Show this help
#
# Options:
# --vault DIR Vault directory (overrides settings.json sync.vault_dir)
# --settings FILE Path to settings file (default: ./settings.json)
# -v Verbose Python output
# -vv Debug Python output
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SETTINGS="$SCRIPT_DIR/settings.json"
# ── Colours ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# ── Helpers ───────────────────────────────────────────────────────────────────
die() { echo -e "${RED}$*${NC}" >&2; exit 1; }
info() { echo -e "${BLUE}$*${NC}"; }
ok() { echo -e "${GREEN}$*${NC}"; }
warn() { echo -e "${YELLOW}$*${NC}"; }
require_cmd() {
command -v "$1" &>/dev/null || die "Required command not found: $1"
}
read_json() {
# read_json <file> <jq_path> e.g. read_json settings.json .sync.vault_dir
jq -r "$2 // empty" "$1" 2>/dev/null || true
}
# ── Argument parsing ──────────────────────────────────────────────────────────
COMMAND="${1:-help}"
shift || true
VAULT_ARG=""
VERBOSE_FLAG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--vault) VAULT_ARG="$2"; shift 2 ;;
--settings) SETTINGS="$2"; shift 2 ;;
-vv) VERBOSE_FLAG="-vv"; shift ;;
-v) VERBOSE_FLAG="-v"; shift ;;
--verbose) VERBOSE_FLAG="-v"; shift ;;
--) shift; break ;;
*) shift ;;
esac
done
# ── Config resolution ─────────────────────────────────────────────────────────
[[ -f "$SETTINGS" ]] || die "settings.json not found at $SETTINGS"
# Vault: CLI flag > settings.json > error
if [[ -n "$VAULT_ARG" ]]; then
VAULT_DIR="$VAULT_ARG"
else
VAULT_DIR="$(read_json "$SETTINGS" '.sync.vault_dir')"
fi
[[ -n "$VAULT_DIR" ]] || die \
"Vault directory not configured.\n" \
" Set sync.vault_dir in settings.json or pass --vault <dir>."
# ── Docker run helper ─────────────────────────────────────────────────────────
run_python() {
# run_python <python_args...>
# Mounts SCRIPT_DIR (read-only) as /work and VAULT_DIR as /vault.
docker run --rm \
--network domnet \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-v "$SCRIPT_DIR:/work:ro" \
-v "$VAULT_DIR:/vault" \
-w /work \
python:3.11-slim \
bash -c "pip install -qqq requests 2>/dev/null && python3 outline_sync.py $*"
}
# ── Commands ──────────────────────────────────────────────────────────────────
cmd_init() {
require_cmd git
require_cmd docker
require_cmd jq
echo
info "════════════════════════════════════════════════════════════"
info " OUTLINE SYNC — init"
info "════════════════════════════════════════════════════════════"
echo
echo " Vault: $VAULT_DIR"
echo " Settings: $SETTINGS"
echo
# Guard: refuse if .git already exists
if [[ -d "$VAULT_DIR/.git" ]]; then
die "Vault is already initialized at $VAULT_DIR\n" \
" Remove the directory first, or use a different --vault path."
fi
# Create vault dir on the host BEFORE Docker mounts it.
# If Docker creates the mount point itself it does so as root,
# making the Python container (running as the host user) unable to write.
mkdir -p "$VAULT_DIR"
# ── Step 1: Python — export Outline content + write config files ──────────
echo
info "Step 1/3 Exporting Outline content..."
echo
if ! run_python "init --vault /vault --settings /work/settings.json $VERBOSE_FLAG"; then
# Clean up incomplete vault so the user can retry cleanly
if [[ -d "$VAULT_DIR" ]]; then
warn "Export failed — removing incomplete vault directory."
rm -rf "$VAULT_DIR"
fi
die "Export step failed. Check the error output above and retry."
fi
echo
# ── Step 2: git init + first commit on outline branch ────────────────────
info "Step 2/3 Initializing git repository..."
# Init the repo
git -C "$VAULT_DIR" init --quiet
# Rename initial branch to 'outline' before the first commit.
# We use symbolic-ref for compatibility with git < 2.28 (no --initial-branch).
git -C "$VAULT_DIR" symbolic-ref HEAD refs/heads/outline
git -C "$VAULT_DIR" add --all
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
git \
-C "$VAULT_DIR" \
-c user.name="outline-sync" \
-c user.email="sync@local" \
commit \
--quiet \
-m "sync: initial import from Outline @ $TIMESTAMP"
ok "Committed to 'outline' branch"
# Create 'main' at the same commit (this is the working branch for Obsidian)
git -C "$VAULT_DIR" checkout --quiet -b main
ok "Created 'main' branch"
# ── Step 3: verify ────────────────────────────────────────────────────────
echo
info "Step 3/3 Verifying..."
local outline_sha main_sha
outline_sha="$(git -C "$VAULT_DIR" rev-parse outline)"
main_sha="$(git -C "$VAULT_DIR" rev-parse main)"
if [[ "$outline_sha" == "$main_sha" ]]; then
ok "Both branches at commit ${outline_sha:0:8}"
else
die "Branch mismatch after init:\n" \
" outline = $outline_sha\n" \
" main = $main_sha"
fi
local doc_count
doc_count="$(find "$VAULT_DIR" -name "*.md" | wc -l | tr -d ' ')"
ok "$doc_count markdown files committed"
echo
info "════════════════════════════════════════════════════════════"
echo -e " ${GREEN}Vault ready at:${NC} $VAULT_DIR"
echo
echo " Next steps:"
echo " 1. Open $VAULT_DIR as your Obsidian vault"
echo " 2. Install the 'Obsidian Git' plugin"
echo " 3. Configure it to auto-commit on the 'main' branch"
info "════════════════════════════════════════════════════════════"
echo
}
cmd_help() {
echo "Outline ↔ Obsidian Sync"
echo
echo "Usage: $0 <command> [options]"
echo
echo "Commands:"
echo " init Initialize vault from Outline (Phase 1)"
echo " help Show this help"
echo
echo "Options:"
echo " --vault DIR Vault directory (overrides settings.json sync.vault_dir)"
echo " --settings FILE Path to settings file (default: ./settings.json)"
echo " -v Verbose output"
echo " -vv Debug output"
echo
echo "Examples:"
echo " $0 init"
echo " $0 init --vault /data/my-vault"
echo " $0 init -v"
echo
}
# ── Dispatch ──────────────────────────────────────────────────────────────────
case "$COMMAND" in
init) cmd_init ;;
help) cmd_help ;;
*) die "Unknown command: '$COMMAND' — run '$0 help' for usage." ;;
esac