OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / scripts / restore.sh

205 lines · bash ref:
#!/bin/bash
#
# Hypernote Restore — Import notes and media from a portable backup
#
# Usage:
#   ./scripts/restore.sh [BACKUP_DIR] [BASE_URL]
#
# Reads the backup structure created by backup.sh and pushes everything
# back into hypernote via the API.

set -euo pipefail

BACKUP="${1:-$HOME/hypernote-backups/2026-04-06-pre-compaction}"
BASE_URL="${2:-http://localhost:4040/hypernote}"
BASE_URL="${BASE_URL%/}"

echo "=== Hypernote Restore ==="
echo "Backup:  $BACKUP"
echo "Target:  $BASE_URL"
echo ""

if [ ! -d "$BACKUP/notebooks" ]; then
  echo "ERROR: No notebooks directory in $BACKUP"
  exit 1
fi

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

echo "WARNING: This will create notebooks and notes that don't already exist."
echo "Existing notes with the same ID will be SKIPPED (not overwritten)."
echo ""
read -p "Continue? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
  echo "Aborted."
  exit 0
fi

TOTAL_NOTES=0
TOTAL_MEDIA=0
SKIPPED=0

for NB_DIR in "$BACKUP"/notebooks/*/; do
  SLUG=$(basename "$NB_DIR")
  echo ""
  echo "=== Notebook: $SLUG ==="

  # Read notebook metadata
  META_FILE="$NB_DIR/metadata.json"
  if [ -f "$META_FILE" ]; then
    NAME=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('name','$SLUG'))" 2>/dev/null || echo "$SLUG")
    DESC=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('description',''))" 2>/dev/null || echo "")
    VIS=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('visibility','public'))" 2>/dev/null || echo "public")
  else
    NAME="$SLUG"
    DESC=""
    VIS="public"
  fi

  # Check if notebook exists
  HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG" 2>/dev/null || echo "000")
  if [ "$HTTP_CODE" != "200" ]; then
    echo "  Creating notebook: $SLUG"
    curl -sf -X POST "$BASE_URL/api/notebooks" \
      -H "Content-Type: application/json" \
      -d "{\"slug\":\"$SLUG\",\"name\":$(python3 -c "import json; print(json.dumps('$NAME'))"),\"description\":$(python3 -c "import json; print(json.dumps('$DESC'))"),\"visibility\":\"$VIS\"}" \
      > /dev/null 2>&1 || echo "  WARN: Failed to create notebook $SLUG (may need auth)"
  else
    echo "  Notebook exists: $SLUG"
  fi

  # Restore notes
  if [ -d "$NB_DIR/notes" ]; then
    for META in "$NB_DIR"/notes/*.meta.json; do
      [ -f "$META" ] || continue

      NOTE_ID=$(python3 -c "import json; print(json.load(open('$META')).get('id',''))" 2>/dev/null)
      NOTE_SLUG=$(python3 -c "import json; print(json.load(open('$META')).get('slug',''))" 2>/dev/null)
      TITLE=$(python3 -c "import json; print(json.load(open('$META')).get('title','Untitled'))" 2>/dev/null)
      TYPE=$(python3 -c "import json; print(json.load(open('$META')).get('type','markdown'))" 2>/dev/null)
      TAGS=$(python3 -c "import json; print(json.dumps(json.load(open('$META')).get('tags',[])))" 2>/dev/null)

      # Check if note already exists
      if [ -n "$NOTE_SLUG" ]; then
        EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/notes/$NOTE_SLUG" 2>/dev/null || echo "000")
      elif [ -n "$NOTE_ID" ]; then
        EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/notes/$NOTE_ID" 2>/dev/null || echo "000")
      else
        EXISTS="000"
      fi

      if [ "$EXISTS" = "200" ]; then
        SKIPPED=$((SKIPPED + 1))
        continue
      fi

      # Read content file
      BASE_NAME=$(basename "$META" .meta.json)
      if [ -f "$NB_DIR/notes/$BASE_NAME.md" ]; then
        CONTENT_FILE="$NB_DIR/notes/$BASE_NAME.md"
      elif [ -f "$NB_DIR/notes/$BASE_NAME.json" ]; then
        CONTENT_FILE="$NB_DIR/notes/$BASE_NAME.json"
      else
        echo "  WARN: No content file for $BASE_NAME"
        continue
      fi

      # Create note via API
      python3 -c "
import json, sys
meta = json.load(open('$META'))
with open('$CONTENT_FILE') as f:
    content = f.read()
payload = {
    'title': meta.get('title', 'Untitled'),
    'content': content,
    'tags': meta.get('tags', []),
    'type': meta.get('type', 'markdown')
}
print(json.dumps(payload))
" 2>/dev/null | curl -sf -X POST "$BASE_URL/api/notebooks/$SLUG/notes" \
        -H "Content-Type: application/json" \
        -d @- > /dev/null 2>&1

      if [ $? -eq 0 ]; then
        TOTAL_NOTES=$((TOTAL_NOTES + 1))
        echo "  + $TITLE"
      else
        echo "  FAIL: $TITLE (may need auth)"
      fi
    done
  fi

  # Restore media
  if [ -d "$NB_DIR/media" ]; then
    for MEDIA_FILE in "$NB_DIR"/media/*; do
      [ -f "$MEDIA_FILE" ] || continue
      FILENAME=$(basename "$MEDIA_FILE")
      HASH="${FILENAME%.*}"  # Strip extension

      # Check if already exists
      EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/media/$HASH" 2>/dev/null || echo "000")
      if [ "$EXISTS" = "200" ]; then continue; fi

      # Detect content type from extension
      case "$FILENAME" in
        *.png)  CT="image/png" ;;
        *.jpg)  CT="image/jpeg" ;;
        *.gif)  CT="image/gif" ;;
        *.webp) CT="image/webp" ;;
        *.mp4)  CT="video/mp4" ;;
        *.webm) CT="video/webm" ;;
        *.mp3)  CT="audio/mpeg" ;;
        *)      CT="application/octet-stream" ;;
      esac

      curl -sf -X POST "$BASE_URL/api/notebooks/$SLUG/media" \
        -H "Content-Type: $CT" \
        --data-binary "@$MEDIA_FILE" > /dev/null 2>&1

      if [ $? -eq 0 ]; then
        TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
      fi
    done
  fi
done

# Also restore from global media-all directory
if [ -d "$BACKUP/media-all" ]; then
  echo ""
  echo "=== Restoring global media ==="
  # Pick first notebook as target for orphan media
  FIRST_SLUG=$(ls "$BACKUP/notebooks/" | head -1)
  for MEDIA_FILE in "$BACKUP"/media-all/*; do
    [ -f "$MEDIA_FILE" ] || continue
    FILENAME=$(basename "$MEDIA_FILE")
    HASH="${FILENAME%.*}"

    EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$FIRST_SLUG/media/$HASH" 2>/dev/null || echo "000")
    if [ "$EXISTS" = "200" ]; then continue; fi

    case "$FILENAME" in
      *.png)  CT="image/png" ;;
      *.jpg)  CT="image/jpeg" ;;
      *.gif)  CT="image/gif" ;;
      *.webp) CT="image/webp" ;;
      *.mp4)  CT="video/mp4" ;;
      *.webm) CT="video/webm" ;;
      *)      CT="application/octet-stream" ;;
    esac

    curl -sf -X POST "$BASE_URL/api/notebooks/$FIRST_SLUG/media" \
      -H "Content-Type: $CT" \
      --data-binary "@$MEDIA_FILE" > /dev/null 2>&1 && TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
  done
fi

echo ""
echo "=== Restore Complete ==="
echo "Notes created:  $TOTAL_NOTES"
echo "Notes skipped:  $SKIPPED (already exist)"
echo "Media uploaded:  $TOTAL_MEDIA"