OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / scripts / backup.sh

208 lines · bash ref:
#!/bin/bash
#
# Hypernote Backup — Export all notebooks to portable format
#
# Usage:
#   ./scripts/backup.sh [BASE_URL] [OUTPUT_DIR]
#
# Output structure:
#   backup/
#   ├── manifest.json
#   ├── users.json
#   ├── notebooks/
#   │   ├── my-notebook/
#   │   │   ├── metadata.json
#   │   │   ├── notes/
#   │   │   │   ├── my-note.md
#   │   │   │   ├── my-note.meta.json
#   │   │   │   └── ...
#   │   │   └── media/
#   │   │       ├── abc123...def.png
#   │   │       └── ...

set -euo pipefail

BASE_URL="${1:-https://7rnx.net/hypernote}"
DATE=$(date +%Y-%m-%d)
OUTPUT="${2:-$HOME/hypernote-backups/$DATE}"
BASE_URL="${BASE_URL%/}"

echo "=== Hypernote Backup ==="
echo "Source:  $BASE_URL"
echo "Output:  $OUTPUT"
echo ""

if ! curl -sf "$BASE_URL/api/health" > /dev/null 2>&1; then
  echo "ERROR: Cannot reach $BASE_URL/api/health"
  exit 1
fi

VERSION=$(curl -sf "$BASE_URL/api/health" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
echo "Server version: $VERSION"
mkdir -p "$OUTPUT"

cat > "$OUTPUT/manifest.json" << EOF
{
  "format": "hypernote-backup-v1",
  "source": "$BASE_URL",
  "serverVersion": "$VERSION",
  "exportedAt": "$(date -Is)",
  "hostname": "$(hostname)"
}
EOF

# Export users
echo "[1/4] Exporting users..."
curl -sf "$BASE_URL/api/admin/users" > "$OUTPUT/users.json" 2>/dev/null || echo '{"users":[]}' > "$OUTPUT/users.json"

# Get notebook list
echo "[2/4] Fetching notebook list..."
curl -sf "$BASE_URL/api/notebooks" > "$OUTPUT/notebooks-index.json"

SLUGS=$(python3 -c "
import json
data = json.load(open('$OUTPUT/notebooks-index.json'))
nbs = data.get('notebooks', data) if isinstance(data, dict) else data
for nb in nbs:
    print(nb.get('slug', ''))
" 2>/dev/null)

if [ -z "$SLUGS" ]; then
  echo "No notebooks found. Done."
  exit 0
fi

NB_COUNT=$(echo "$SLUGS" | wc -l)
echo "Found $NB_COUNT notebooks"

# Export each notebook
echo "[3/4] Exporting notebooks..."
CURRENT=0
TOTAL_NOTES=0
TOTAL_MEDIA=0

for SLUG in $SLUGS; do
  CURRENT=$((CURRENT + 1))
  NB_DIR="$OUTPUT/notebooks/$SLUG"
  NOTES_DIR="$NB_DIR/notes"
  MEDIA_DIR="$NB_DIR/media"
  mkdir -p "$NOTES_DIR" "$MEDIA_DIR"

  # Notebook metadata
  curl -sf "$BASE_URL/api/notebooks/$SLUG" > "$NB_DIR/metadata.json" 2>/dev/null || echo "{}" > "$NB_DIR/metadata.json"

  # Get notes as raw JSON file first, then process with python
  curl -sf "$BASE_URL/api/notebooks/$SLUG/notes?limit=500" > "$NB_DIR/notes-raw.json" 2>/dev/null || echo '{"notes":[]}' > "$NB_DIR/notes-raw.json"

  # Parse and save each note
  COUNTS=$(python3 << 'PYEOF'
import json, os, re, sys

nb_dir = os.environ.get("NB_DIR", ".")
notes_dir = os.environ.get("NOTES_DIR", ".")

with open(os.path.join(nb_dir, "notes-raw.json")) as f:
    data = json.load(f)

notes = data.get("notes", [])
media_hashes = set()

for note in notes:
    nid = note.get("slug") or note.get("id", "unknown")
    # Sanitize filename
    nid = re.sub(r'[^\w\-]', '_', nid)
    ntype = note.get("type", "markdown")
    content = note.get("content", "")

    # Save content
    if ntype == "markdown":
        with open(os.path.join(notes_dir, nid + ".md"), "w") as f:
            f.write(content)
    else:
        with open(os.path.join(notes_dir, nid + ".json"), "w") as f:
            f.write(content if content else "{}")

    # Save metadata sidecar
    meta = {
        "id": note.get("id"),
        "slug": note.get("slug"),
        "title": note.get("title"),
        "tags": note.get("tags", []),
        "type": ntype,
        "createdAt": note.get("createdAt"),
        "updatedAt": note.get("updatedAt")
    }
    with open(os.path.join(notes_dir, nid + ".meta.json"), "w") as f:
        json.dump(meta, f, indent=2)

    # Extract media hashes
    for m in re.findall(r'/media/([a-f0-9]{64})', content):
        media_hashes.add(m)

# Write media manifest
with open(os.path.join(nb_dir, "media-manifest.json"), "w") as f:
    json.dump(sorted(media_hashes), f, indent=2)

print(f"{len(notes)} {len(media_hashes)}")
PYEOF
  )

  NOTE_COUNT=$(echo "$COUNTS" | awk '{print $1}')
  MEDIA_COUNT=$(echo "$COUNTS" | awk '{print $2}')
  TOTAL_NOTES=$((TOTAL_NOTES + NOTE_COUNT))

  # Clean up raw json
  rm -f "$NB_DIR/notes-raw.json"

  echo "  [$CURRENT/$NB_COUNT] $SLUG: $NOTE_COUNT notes, $MEDIA_COUNT media refs"
done

# Download media files
echo "[4/4] Downloading media files..."
for SLUG in $SLUGS; do
  NB_DIR="$OUTPUT/notebooks/$SLUG"
  MEDIA_DIR="$NB_DIR/media"
  MANIFEST="$NB_DIR/media-manifest.json"

  if [ ! -f "$MANIFEST" ]; then continue; fi

  HASHES=$(python3 -c "import json; [print(h) for h in json.load(open('$MANIFEST'))]" 2>/dev/null || true)

  for HASH in $HASHES; do
    if [ -z "$HASH" ]; then continue; fi
    if [ -f "$MEDIA_DIR/$HASH" ]; then continue; fi

    HTTP_CODE=$(curl -sf -o "$MEDIA_DIR/$HASH.tmp" -w "%{http_code}" \
      "$BASE_URL/api/notebooks/$SLUG/media/$HASH" 2>/dev/null || echo "000")

    if [ "$HTTP_CODE" = "200" ] && [ -f "$MEDIA_DIR/$HASH.tmp" ]; then
      # Detect extension from content-type
      CTYPE=$(curl -sf -I "$BASE_URL/api/notebooks/$SLUG/media/$HASH" 2>/dev/null | grep -i content-type | head -1 | tr -d '\r' | awk '{print $2}')
      case "$CTYPE" in
        image/png)  EXT=".png" ;;
        image/jpeg) EXT=".jpg" ;;
        image/gif)  EXT=".gif" ;;
        image/webp) EXT=".webp" ;;
        video/mp4)  EXT=".mp4" ;;
        video/webm) EXT=".webm" ;;
        audio/mpeg) EXT=".mp3" ;;
        *)          EXT="" ;;
      esac
      mv "$MEDIA_DIR/$HASH.tmp" "$MEDIA_DIR/$HASH$EXT"
      TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
    else
      rm -f "$MEDIA_DIR/$HASH.tmp"
      echo "    WARN: Failed to download media $HASH ($HTTP_CODE)"
    fi
  done
done

# Summary
echo ""
echo "=== Backup Complete ==="
echo "Location:   $OUTPUT"
echo "Notebooks:  $NB_COUNT"
echo "Notes:      $TOTAL_NOTES"
echo "Media:      $TOTAL_MEDIA files"
echo "Size:       $(du -sh "$OUTPUT" | cut -f1)"