OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / scripts / restore-direct.js

212 lines · javascript ref:
#!/usr/bin/env node

/**
 * Direct restore — import notes from portable backup directly into Hyperdrives.
 * Bypasses HTTP API (no auth needed). Server must be STOPPED.
 *
 * Usage:
 *   node scripts/restore-direct.js <backup-dir> [storage-path]
 */

import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import Hyperdrive from 'hyperdrive'
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'

const args = process.argv.slice(2)
const backupDir = args[0]
const storagePath = args[1] || process.env.HOME + '/.config/hypernote/server-data'

if (!backupDir || !fs.existsSync(backupDir)) {
  console.log('Usage: node scripts/restore-direct.js <backup-dir> [storage-path]')
  process.exit(1)
}

console.log('=== Direct Restore ===')
console.log('Backup:', backupDir)
console.log('Storage:', storagePath)
console.log('')

const store = new Corestore(storagePath)
await store.ready()

const registryCore = store.get({ name: 'registry' })
const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
await registry.ready()

// Build drive index from registry
const driveIndex = new Map()
for await (const entry of registry.createReadStream({ gte: 'drive:', lt: 'drive:\xff' })) {
  const slug = entry.key.replace('drive:', '')
  driveIndex.set(slug, entry.value)
}

let totalRestored = 0
let totalSkipped = 0
let totalCreated = 0

const notebooksDir = path.join(backupDir, 'notebooks')
if (!fs.existsSync(notebooksDir)) {
  console.log('No notebooks directory found')
  await store.close()
  process.exit(1)
}

for (const slug of fs.readdirSync(notebooksDir)) {
  const nbDir = path.join(notebooksDir, slug)
  if (!fs.statSync(nbDir).isDirectory()) continue

  const notesDir = path.join(nbDir, 'notes')
  if (!fs.existsSync(notesDir)) continue

  console.log(`\n=== ${slug} ===`)

  // Get or create drive for this notebook
  const driveInfo = driveIndex.get(slug)
  let drive
  if (driveInfo && driveInfo.key) {
    // Skip remote drives — we can only write to local ones
    if (driveInfo.isLocal === false) {
      console.log('  SKIP: remote notebook (read-only)')
      continue
    }
    drive = new Hyperdrive(store.namespace('notebook-' + slug), Buffer.from(driveInfo.key, 'hex'))
    await Promise.race([
      drive.ready(),
      new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 10000))
    ]).catch(() => { drive = null })
  } else {
    // Create new drive for this notebook
    drive = new Hyperdrive(store.namespace('notebook-' + slug))
    await drive.ready()
    const keyHex = drive.key.toString('hex')
    const dkeyHex = drive.discoveryKey.toString('hex')
    await registry.put('drive:' + slug, { key: keyHex, discoveryKey: dkeyHex, slug, isLocal: true })
    driveIndex.set(slug, { key: keyHex })
    console.log('  Created drive for: ' + slug)
    totalCreated++
  }

  // Check if drive opened and is writable
  if (!drive) {
    console.log('  SKIP: drive failed to open')
    continue
  }
  if (!drive.db.feed.writable) {
    console.log('  SKIP: drive is read-only (remote notebook)')
    continue
  }

  // Read existing note IDs from drive
  const existingIds = new Set()
  try {
    for await (const f of drive.readdir('/notes')) {
      if (f.endsWith('.json')) existingIds.add(f.replace('.json', ''))
    }
  } catch {}

  // Find all meta files in backup
  const metaFiles = fs.readdirSync(notesDir).filter(f => f.endsWith('.meta.json'))

  for (const metaFile of metaFiles) {
    const meta = JSON.parse(fs.readFileSync(path.join(notesDir, metaFile), 'utf-8'))
    const noteId = meta.id
    const noteSlug = meta.slug

    // Skip if already exists
    if (existingIds.has(noteId)) {
      totalSkipped++
      continue
    }

    // Read content
    const baseName = metaFile.replace('.meta.json', '')
    let content = ''
    const mdFile = path.join(notesDir, baseName + '.md')
    const jsonFile = path.join(notesDir, baseName + '.json')
    if (fs.existsSync(mdFile)) {
      content = fs.readFileSync(mdFile, 'utf-8')
    } else if (fs.existsSync(jsonFile)) {
      content = fs.readFileSync(jsonFile, 'utf-8')
    }

    // Write note to drive
    const noteObj = {
      id: noteId,
      slug: noteSlug || baseName,
      title: meta.title || 'Untitled',
      content,
      tags: meta.tags || [],
      type: meta.type || 'markdown',
      createdAt: meta.createdAt || Date.now(),
      updatedAt: meta.updatedAt || Date.now()
    }

    await drive.put('/notes/' + noteId + '.json', Buffer.from(JSON.stringify(noteObj, null, 2)))
    console.log('  + ' + (meta.title || noteId))
    totalRestored++
  }

  // Update notebook metadata noteCount
  const nbMeta = await registry.get('notebook:' + slug)
  if (nbMeta && nbMeta.value) {
    const updated = { ...nbMeta.value, noteCount: existingIds.size + totalRestored }
    await registry.put('notebook:' + slug, updated)
  }
}

// Restore media from media-all
const mediaAllDir = path.join(backupDir, 'media-all')
if (fs.existsSync(mediaAllDir)) {
  console.log('\n=== Restoring media ===')

  // Open MediaStore
  const mediaStore = store.namespace('media-store')
  const blobCore = mediaStore.get({ name: 'blobs' })
  const indexCore = mediaStore.get({ name: 'index' })
  await blobCore.ready()
  await indexCore.ready()

  // Dynamic import for Hyperblobs
  const { default: Hyperblobs } = await import('hyperblobs')
  const blobs = new Hyperblobs(blobCore)
  const index = new Hyperbee(indexCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await index.ready()

  let mediaRestored = 0
  let mediaSkipped = 0

  for (const file of fs.readdirSync(mediaAllDir)) {
    const ext = path.extname(file)
    const hash = path.basename(file, ext)

    // Skip if already in index
    const existing = await index.get('blob:' + hash)
    if (existing) { mediaSkipped++; continue }

    const MIME = {
      '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
      '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
      '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg'
    }
    const mimeType = MIME[ext] || 'application/octet-stream'

    const data = fs.readFileSync(path.join(mediaAllDir, file))
    const blobId = await blobs.put(data)
    await index.put('blob:' + hash, { blobId, size: data.length, mimeType, createdAt: Date.now() })
    console.log('  + ' + file + ' (' + (data.length / 1024).toFixed(0) + 'KB)')
    mediaRestored++
  }

  console.log(`\nMedia: ${mediaRestored} restored, ${mediaSkipped} skipped (already exist)`)
}

console.log(`\n=== Restore Complete ===`)
console.log(`Notes restored: ${totalRestored}`)
console.log(`Notes skipped: ${totalSkipped} (already exist)`)
console.log(`Drives created: ${totalCreated}`)

await store.close()