OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / site-publisher.js

260 lines · javascript ref:
/**
 * SitePublisher — Mirror web app + public notebooks to a single Hyperdrive
 *
 * Creates a "site drive" that can be accessed via hyper:// in Agregore browser.
 * The drive contains:
 *   /index.html, /web/css/*, /web/js/*    — SPA files
 *   /api/notebooks.json                    — notebook listing
 *   /api/notebooks/{slug}/metadata.json    — notebook metadata
 *   /api/notebooks/{slug}/notes.json       — note listing
 *   /api/notebooks/{slug}/notes/{id}.json  — individual notes
 *   /api/recent.json                       — recent notes feed
 *   /api/media/{slug}/*                    — media files
 */

import fs from 'bare-fs'
import path from 'bare-path'
import Hyperdrive from 'hyperdrive'

export class SitePublisher {
  constructor (store, noteManager, driveManager) {
    this.store = store
    this.noteManager = noteManager
    this.driveManager = driveManager
    this.drive = null
    this._ready = false
  }

  /**
   * Initialize the site drive.
   */
  async ready () {
    if (this._ready) return

    this.drive = new Hyperdrive(this.store.namespace('site-public'))
    await this.drive.ready()
    this._ready = true
  }

  /**
   * Get the site drive key (hex).
   */
  get key () {
    return this.drive ? this.drive.key.toString('hex') : null
  }

  /**
   * Get the discovery key for Hyperswarm announcement.
   */
  get discoveryKey () {
    return this.drive ? this.drive.discoveryKey : null
  }

  /**
   * Full publish — mirror web files + all public notebook data.
   */
  async publish () {
    await this.ready()
    await this._publishWebFiles()
    await this._publishNotebooks()
    console.log('[SitePublisher] Published site to hyper://' + this.key + '/')
  }

  /**
   * Publish updated data for a specific notebook (call after note save/delete).
   */
  async publishNotebook (slug) {
    await this.ready()

    const notebook = await this.noteManager.getNotebook(slug)
    if (!notebook) return

    // Skip private/encrypted
    if (notebook.visibility === 'private' || notebook.encrypted) {
      // Remove if previously published
      await this._removeNotebookData(slug)
      await this._publishNotebookListing()
      await this._publishRecentNotes()
      return
    }

    await this._publishNotebookData(slug, notebook)
    await this._publishNotebookListing()
    await this._publishRecentNotes()
    await this._publishNotebookMedia(slug)
  }

  // ==================== INTERNAL ====================

  /**
   * Mirror the web app files (index.html, css, js) into the site drive.
   */
  async _publishWebFiles () {
    const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'web')

    // index.html
    const indexHtml = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf-8')
    await this.drive.put('/index.html', Buffer.from(indexHtml))

    // CSS files
    const cssDir = path.join(webRoot, 'css')
    try {
      const cssFiles = fs.readdirSync(cssDir)
      for (const file of cssFiles) {
        const content = fs.readFileSync(path.join(cssDir, file))
        await this.drive.put('/web/css/' + file, content)
      }
    } catch {}

    // JS files
    const jsDir = path.join(webRoot, 'js')
    try {
      const jsFiles = fs.readdirSync(jsDir)
      for (const file of jsFiles) {
        const content = fs.readFileSync(path.join(jsDir, file))
        await this.drive.put('/web/js/' + file, content)
      }
    } catch {}
  }

  /**
   * Publish all public notebook data as static JSON.
   */
  async _publishNotebooks () {
    const notebooks = await this.noteManager.listNotebooks()

    for (const nb of notebooks) {
      if (nb.visibility === 'private' || nb.encrypted) continue
      await this._publishNotebookData(nb.slug, nb)
      await this._publishNotebookMedia(nb.slug)
    }

    await this._publishNotebookListing()
    await this._publishRecentNotes()
  }

  /**
   * Publish a single notebook's data (metadata + notes).
   */
  async _publishNotebookData (slug, notebook) {
    // Metadata
    const meta = {
      slug: notebook.slug,
      name: notebook.name,
      description: notebook.description,
      noteCount: notebook.noteCount,
      visibility: notebook.visibility,
      createdAt: notebook.createdAt,
      updatedAt: notebook.updatedAt
    }
    await this.drive.put(
      '/api/notebooks/' + slug + '/metadata.json',
      Buffer.from(JSON.stringify(meta, null, 2))
    )

    // Notes
    const notes = await this.noteManager.listNotes(slug)
    const noteSummaries = notes.map(n => ({
      id: n.id,
      title: n.title,
      tags: n.tags || [],
      createdAt: n.createdAt,
      updatedAt: n.updatedAt,
      snippet: (n.content || '').substring(0, 200)
    }))
    await this.drive.put(
      '/api/notebooks/' + slug + '/notes.json',
      Buffer.from(JSON.stringify({ notes: noteSummaries }, null, 2))
    )

    // Individual notes
    for (const note of notes) {
      await this.drive.put(
        '/api/notebooks/' + slug + '/notes/' + note.id + '.json',
        Buffer.from(JSON.stringify(note, null, 2))
      )
    }
  }

  /**
   * Copy media files from notebook drive to site drive.
   */
  async _publishNotebookMedia (slug) {
    const nbDrive = await this.driveManager.getDrive(slug)
    if (!nbDrive) return

    try {
      const mediaFiles = await nbDrive.readdir('/media')
      for await (const file of mediaFiles) {
        const buf = await nbDrive.get('/media/' + file)
        if (buf) {
          await this.drive.put('/api/media/' + slug + '/' + file, buf)
        }
      }
    } catch {
      // No media directory
    }
  }

  /**
   * Publish the notebook listing JSON.
   */
  async _publishNotebookListing () {
    const notebooks = await this.noteManager.listNotebooks()
    const publicNbs = notebooks
      .filter(nb => nb.visibility !== 'private' && !nb.encrypted)
      .map(nb => ({
        slug: nb.slug,
        name: nb.name,
        description: nb.description,
        noteCount: nb.noteCount,
        visibility: nb.visibility,
        createdAt: nb.createdAt,
        updatedAt: nb.updatedAt
      }))

    await this.drive.put(
      '/api/notebooks.json',
      Buffer.from(JSON.stringify({ notebooks: publicNbs }, null, 2))
    )
  }

  /**
   * Publish the recent notes feed JSON.
   */
  async _publishRecentNotes () {
    const recent = await this.noteManager.recentNotes({ limit: 50 })
    await this.drive.put(
      '/api/recent.json',
      Buffer.from(JSON.stringify({ notes: recent }, null, 2))
    )
  }

  /**
   * Remove a notebook's data from the site drive.
   */
  async _removeNotebookData (slug) {
    try {
      await this.drive.del('/api/notebooks/' + slug + '/metadata.json')
      await this.drive.del('/api/notebooks/' + slug + '/notes.json')

      // Remove individual note files
      const noteFiles = await this.drive.readdir('/api/notebooks/' + slug + '/notes')
      for await (const file of noteFiles) {
        await this.drive.del('/api/notebooks/' + slug + '/notes/' + file)
      }
    } catch {
      // May not exist
    }

    try {
      const mediaFiles = await this.drive.readdir('/api/media/' + slug)
      for await (const file of mediaFiles) {
        await this.drive.del('/api/media/' + slug + '/' + file)
      }
    } catch {}
  }
}

export default SitePublisher