Add sync engine, web UI, Docker setup, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
230
sync.sh
Executable file
230
sync.sh
Executable 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
|
||||
Reference in New Issue
Block a user