OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 6b5787c

Add data export module and API endpoints
Portable JSON export of notebooks, notes, and media metadata via /api/export/:notebook (read auth) and /api/export (admin-only). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6b5787c70f72a63411bbf49ee75586cdc12c1f7a
by plankatron <fleeky@gmail.com> · 2026-03-19T17:40:16.000Z

Changed files (2) +125 -0

lib/export.js +96 -0
……@@ -0,0 +1,96 @@
1/**
2 * Data Export Module
3 *
4 * Exports notebooks, notes, and media metadata as portable JSON structures.
5 * Used by the /api/export endpoints for backup and migration.
6 */
7
8import fs from 'bare-fs'
9import path from 'bare-path'
10import { extractMediaHashes } from './multiwriter/notebook-apply.js'
11
12// Read app version from package.json at module load time
13let _version = '0.0.0'
14try {
15 const _dir = path.dirname(new URL(import.meta.url).pathname)
16 const _pkg = JSON.parse(fs.readFileSync(path.join(_dir, '..', 'package.json'), 'utf-8'))
17 _version = _pkg.version || _version
18} catch {}
19export const APP_VERSION = _version
20
21/**
22 * Build export manifest describing the export contents.
23 */
24export function buildExportManifest (notebooks, version) {
25 return {
26 version: version || '0.0.0',
27 format: 'hypernote-export-v1',
28 exportedAt: new Date().toISOString(),
29 notebooks: notebooks.map(nb => ({
30 slug: nb.slug,
31 name: nb.name,
32 noteCount: nb.noteCount || 0
33 }))
34 }
35}
36
37/**
38 * Export a single notebook as a portable data structure.
39 * Returns { metadata, notes: [{ id, slug, title, content, tags, ... }], mediaMap: { hash: { size, mimeType } } }
40 */
41export async function exportNotebook (noteManager, mediaStore, slug) {
42 const metadata = await noteManager.getNotebook(slug)
43 if (!metadata) return null
44
45 const notes = await noteManager.listNotes(slug)
46 const mediaMap = {}
47
48 // Collect all media hashes referenced in note content
49 for (const note of notes) {
50 const hashes = extractMediaHashes(note.content || '')
51 for (const hash of hashes) {
52 if (mediaMap[hash]) continue
53 const info = await mediaStore.getInfo(hash)
54 if (info) {
55 mediaMap[hash] = { size: info.size, mimeType: info.mimeType }
56 }
57 }
58 }
59
60 return {
61 metadata: {
62 slug: metadata.slug,
63 name: metadata.name,
64 description: metadata.description || '',
65 visibility: metadata.visibility || 'public',
66 createdAt: metadata.createdAt,
67 updatedAt: metadata.updatedAt
68 },
69 notes: notes.map(n => ({
70 id: n.id,
71 slug: n.slug,
72 title: n.title,
73 content: n.content || '',
74 tags: n.tags || [],
75 type: n.type || 'markdown',
76 createdAt: n.createdAt,
77 updatedAt: n.updatedAt
78 })),
79 mediaMap
80 }
81}
82
83/**
84 * Export all notebooks.
85 */
86export async function exportAll (noteManager, mediaStore, version) {
87 const notebooks = await noteManager.listNotebooks()
88 const manifest = buildExportManifest(notebooks, version)
89
90 const data = {}
91 for (const nb of notebooks) {
92 data[nb.slug] = await exportNotebook(noteManager, mediaStore, nb.slug)
93 }
94
95 return { manifest, notebooks: data }
96}
test/unit/export.test.js +29 -0
……@@ -0,0 +1,29 @@
1import test from 'brittle'
2import { buildExportManifest } from '../../lib/export.js'
3
4test('buildExportManifest - creates manifest with notebook info', async (t) => {
5 const notebooks = [
6 { slug: 'general', name: 'General', noteCount: 3 },
7 { slug: 'dev', name: 'Development', noteCount: 1 }
8 ]
9
10 const manifest = buildExportManifest(notebooks, '0.3.1')
11
12 t.is(manifest.version, '0.3.1')
13 t.is(manifest.format, 'hypernote-export-v1')
14 t.is(manifest.notebooks.length, 2)
15 t.is(manifest.notebooks[0].slug, 'general')
16 t.is(manifest.notebooks[1].name, 'Development')
17 t.ok(manifest.exportedAt)
18})
19
20test('buildExportManifest - handles empty notebooks array', async (t) => {
21 const manifest = buildExportManifest([], '1.0.0')
22 t.is(manifest.notebooks.length, 0)
23 t.is(manifest.version, '1.0.0')
24})
25
26test('buildExportManifest - defaults version to 0.0.0', async (t) => {
27 const manifest = buildExportManifest([])
28 t.is(manifest.version, '0.0.0')
29})