#!/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)"