OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / e0d5db8

Add event-driven multiwriter sync, SSR foundation, backup/restore tooling
Multiwriter: - Replace 60s polling timer with event-driven sync (pear-git pattern) - Sync triggers on swarm peer connection, not setInterval - Add dedup guards to prevent duplicate Autobase writes - Prevents write storms that caused 580x bloat and 2GB+ memory usage Backup/Restore: - scripts/backup.sh — portable export (markdown + metadata + media via API) - scripts/restore.sh — API-based restore with auth - scripts/restore-direct.js — direct Hyperdrive import (no auth, server stopped) - scripts/extract-media.js — extract blobs from raw corestore - scripts/compact-autobases.js — inspect/compact bloated Autobases SSR Foundation (prepared, not yet wired as default): - lib/markdown.js — server-side markdown renderer - lib/page-layout.js — renderPage() following pear-git pattern - lib/nav-ui.js — shared navigation bar - lib/identity-ui.js — identity script injection - lib/services/ — business logic layer (notebook, note, media, public-page) - lib/views/ — 8 SSR page templates (home, notebook, note, search, user, profile, admin, editor) Docs: - README: NODE_OPTIONS, backup/restore, compaction, updated architecture - architecture.md: service layer, event-driven sync pattern Fix: CSP font-src allows data: URIs (TOUI editor inline fonts) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e0d5db867082a834146a139778ff10cfe0158534
by plankatron <fleeky@gmail.com> · 2026-04-07T00:21:50.000Z

Changed files (25) +3853 -104

README.md +134 -36
……@@ -70,18 +70,20 @@ hypernote serve
7070
7171```bash
7272# Start server (localhost:4040)
73bare hypernote-server.js serve
73NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve
7474
7575# Start on LAN (accessible from other devices)
76bare hypernote-server.js serve 4040 --network
76NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 4040 --network
7777
7878# Custom port, storage, and URL prefix
79bare hypernote-server.js serve 5555 --storage=/path/to/data --prefix=/hypernote
79NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 5555 --storage=/path/to/data --prefix=/hypernote
8080
8181# Promote a user to admin (CLI)
8282bare hypernote-server.js promote <publicKey|username>
8383```
8484
85> **Note:** `NODE_OPTIONS="--max-old-space-size=4096"` is required for larger corestores. The default V8 heap (1.7GB) may be too small for servers with many notebooks and shared Autobases.
86
8587On first start, the server prints an admin bearer token to the console. Open `http://localhost:4040` in your browser -- an ed25519 identity is generated automatically and the first user is promoted to admin.
8688
8789### Reverse Proxy
……@@ -155,30 +157,37 @@ hn config set token <bearer-token>
155157
156158```
157159Browser (SPA) Server (Bare runtime)
158 identity.js AuthManager
159 ed25519 keypair WebIdentityProvider
160 challenge signing BearerTokenProvider
161 fetch interception KeetIdentityProvider
162 api.js NoteManager
163 HTTP client per-notebook CRUD
164 hyper:// dual-mode DriveManager
165 app.js Hyperdrive lifecycle
166 hash router ReplicationManager
167 Toast UI Editor Hyperswarm P2P sync
168 IdentityRegistry
169 user accounts + permissions
170 LinkManager
171 wiki-links + backlinks
172 NotebookCrypto
173 per-notebook encryption
174 MediaStore
175 SHA-256 content-addressed blobs
176 VideoProcessor
177 HLS transcoding (ffmpeg)
178 SitePublisher
179 hyper:// site drive
160 identity.js Routes (HTTP handlers)
161 ed25519 keypair ↓
162 challenge signing Services (business logic)
163 fetch interception NotebookService
164 api.js NoteService
165 HTTP client MediaService
166 hyper:// dual-mode PublicPageService
167 app.js ↓
168 hash router Managers (data access)
169 Toast UI Editor NoteManager — per-notebook CRUD
170 DriveManager — Hyperdrive lifecycle
171CLI (bin/hn) AuthManager — multi-method auth
172 HTTP client ReplicationManager — Hyperswarm P2P
173 config management MultiwriterManager — Autobase sync
174 LinkManager — wiki-links + backlinks
175 MediaStore — SHA-256 blobs
176 VideoProcessor — HLS (ffmpeg)
177 SitePublisher — hyper:// site
180178```
181179
180**Three interfaces to the same server:**
181- **Web SPA** — hash-routing, Toast UI editor, client-side markdown
182- **CLI** (`bin/hn`) — calls HTTP API with bearer token auth
183- **REST API** — JSON endpoints at `/api/*`
184
185**SSR foundation** (prepared, not yet wired as default):
186- `lib/views/*-ui.js` — server-rendered HTML pages
187- `lib/page-layout.js` — shared page wrapper
188- `lib/markdown.js` — server-side markdown renderer
189- `lib/nav-ui.js` — shared navigation bar
190
182191### Storage Layout
183192
184193```
……@@ -231,15 +240,19 @@ Hypernote supports linking multiple instances via [Autobase](https://github.com/
2312401. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex
2322412. An admin on instance B joins with that invite (`POST /api/multiwriter/join`)
2332423. Both instances share a replicated registry (users, permissions) via Autobase
2344. Mirror sync copies notebooks between instances in the background
2434. Mirror sync runs event-driven — triggers on peer connection, not on a timer
244
245**Event-driven sync** (following the pear-git/keet pattern):
246- Writer exchange happens over Protomux when peers connect via Hyperswarm
247- `base.update()` is called before reads, not on a polling interval
248- Autobase's built-in `ackInterval` handles internal acknowledgments
249- No `setInterval` polling — sync is reactive to peer events
235250
236251**Key endpoints:**
237252- `GET /api/multiwriter/status` -- check linking state and peers
238253- `POST /api/multiwriter/link` / `POST /api/multiwriter/join` -- instance linking
239254- `POST /api/multiwriter/sync` -- trigger manual mirror sync
240- `POST /api/multiwriter/mirror` -- start/stop background mirror sync
241255- `POST /api/notebooks/:nb/share` -- toggle Autobase sharing on a notebook
242- Shared notebooks get their own CRDT-based note endpoints (`/shared-notes`, Yjs updates)
243256
244257See [docs/api.md](docs/api.md) for the complete multiwriter API reference.
245258
……@@ -344,11 +357,63 @@ The CI pipeline (`.pear-git/ci.yaml`) installs dependencies, then pear-git runs
344357### Manual
345358
346359```bash
347bare hypernote-server.js serve 4040 --network
360NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 4040 --network
348361```
349362
350363For production, run in tmux or as a systemd service.
351364
365## Backup & Restore
366
367Portable backup/restore scripts export notes as markdown files with JSON metadata sidecars, and media as original files with content-addressed filenames.
368
369```bash
370# Backup all notebooks, notes, and media to portable format
371./scripts/backup.sh http://localhost:4040/hypernote ~/hypernote-backups/$(date +%Y-%m-%d)
372
373# Restore from portable backup (via API, requires auth)
374./scripts/restore.sh ~/hypernote-backups/2026-04-06 http://localhost:4040/hypernote
375
376# Direct restore into Hyperdrives (no auth needed, server must be stopped)
377node scripts/restore-direct.js ~/hypernote-backups/2026-04-06
378
379# Extract media blobs from a raw corestore backup
380node scripts/extract-media.js ~/.config/hypernote/server-data ~/media-output
381```
382
383**Backup output structure:**
384```
385backup/
386├── manifest.json # export metadata
387├── users.json # user accounts
388├── notebooks/
389│ ├── my-notebook/
390│ │ ├── metadata.json # notebook name, description, visibility
391│ │ ├── media-manifest.json # referenced media hashes
392│ │ ├── notes/
393│ │ │ ├── my-note.md # note content as markdown
394│ │ │ ├── my-note.meta.json # note ID, slug, title, tags, timestamps
395│ │ │ └── ...
396│ │ └── media/
397│ │ └── ... # downloaded media files
398│ └── ...
399└── media-all/ # all extracted media from MediaStore
400```
401
402### Autobase Compaction
403
404If multiwriter Autobases become bloated (from sync loops or excessive writes), compact them:
405
406```bash
407# Dry run — inspect bloat ratios
408node scripts/compact-autobases.js ~/.config/hypernote/server-data
409
410# Apply — deletes multiwriter config, server creates fresh Autobases on restart
411node scripts/compact-autobases.js ~/.config/hypernote/server-data --apply
412
413# After compaction, restore notes into Hyperdrives
414node scripts/restore-direct.js ~/hypernote-backups/latest
415```
416
352417## Testing
353418
354419```bash
……@@ -369,6 +434,12 @@ hypernote/
369434 package.json
370435 bin/hn # CLI entry point
371436 .pear-git/ci.yaml # pear-git CI config
437 scripts/
438 backup.sh # portable backup (notes + media via API)
439 restore.sh # restore via API (requires auth)
440 restore-direct.js # restore into Hyperdrives (no auth, server stopped)
441 extract-media.js # extract media blobs from raw corestore
442 compact-autobases.js # inspect/compact bloated Autobases
372443 lib/
373444 auth.js # AuthManager (multi-method)
374445 note-manager.js # notebook + note CRUD
……@@ -378,23 +449,44 @@ hypernote/
378449 notebook-crypto.js # per-notebook encryption
379450 link-manager.js # wiki-links + backlinks + graph
380451 media-store.js # SHA-256 content-addressed blob storage
452 markdown.js # server-side markdown renderer (SSR)
453 page-layout.js # SSR page layout (renderPage)
454 nav-ui.js # SSR navigation bar (renderNav)
455 identity-ui.js # SSR identity script injection
381456 export.js # data export (notebook + full server)
382457 anproto.js # ANProto ed25519 signatures
383458 storage-locator.js # storage breadcrumb system
384459 errors.js # custom error classes
385460 response.js # HTTP response helpers
461 services/
462 notebook-service.js # notebook business logic
463 note-service.js # note CRUD + side effects
464 media-service.js # media upload + resolve + HLS
465 public-page-service.js # public page data preparation
466 views/
467 home-ui.js # SSR home page (recent feed)
468 notebook-ui.js # SSR notebook view
469 note-ui.js # SSR note view (rendered markdown)
470 search-ui.js # SSR search results
471 user-ui.js # SSR user public profile
472 profile-ui.js # SSR profile/settings page
473 admin-ui.js # SSR admin dashboard
474 editor-ui.js # SSR editor shell (TOUI/spreadsheet/calendar)
386475 media/
387476 video-processor.js # HLS transcoding via ffmpeg
388477 multiwriter/
389 manager.js # multi-writer orchestration
478 manager.js # multi-writer orchestration (event-driven sync)
390479 instance-linker.js # invite-based instance linking
391480 shared-registry.js # Autobase-backed shared user registry
481 shared-notebook.js # per-notebook Autobase
392482 registry-apply.js # registry operation application
483 notebook-apply.js # notebook operation application
393484 auth/
394485 base-provider.js # auth provider interface
395 bearer-token.js # bearer token auth
396 web-identity.js # browser ed25519 auth
397 keet-identity.js # BIP39 mnemonic auth
486 providers/
487 bearer-token.js # bearer token auth
488 web-identity.js # browser ed25519 auth
489 keet-identity.js # BIP39 mnemonic auth
398490 challenge-manager.js # challenge-response protocol
399491 identity-registry.js # user + identity storage
400492 rate-limiter.js # per-IP rate limiting
……@@ -404,6 +496,7 @@ hypernote/
404496 routes/
405497 index.js # HTTP router
406498 auth.js # identity + auth endpoints
499 auth-helpers.js # shared auth helpers (requireAuth)
407500 notebooks.js # notebook + note + media endpoints
408501 sharing.js # encryption + sharing endpoints
409502 links.js # links + backlinks + graph endpoints
……@@ -418,22 +511,27 @@ hypernote/
418511 web/
419512 index.html # SPA entry point
420513 js/
421 app.js # main app + router + views
514 app.js # main SPA app + router + views
422515 api.js # HTTP client (dual-mode: HTTP + hyper://)
423516 identity.js # ed25519 auth + fetch interception
424 markdown.js # markdown rendering
517 markdown.js # client-side markdown rendering
518 theme.js # theme loader (dark/light/dark-blue)
425519 css/
426 app.css # dark theme styles
520 app.css # themed styles (dark, dark-blue, light)
427521 vendor/
428522 toastui-editor-all.min.js # Toast UI editor (local, no CDN)
429523 toastui-editor.min.css # Toast UI styles (local, no CDN)
430524 hls.min.js # HLS.js video player (local, no CDN)
525 jspreadsheet.js # spreadsheet editor (local)
526 jsuites.js # jSuites dependency (local)
527 fullcalendar.min.js # calendar editor (local)
431528 test/
432529 unit/ # 17 test files (brittle)
433530 helpers/
434531 fixtures.js # test helpers
435532 docs/
436533 api.md # REST API reference
534 architecture.md # architecture deep dive
437535```
438536
439537## License
docs/architecture.md +42 -25
……@@ -4,32 +4,39 @@
44
55```
66Browser (SPA) Server (Bare runtime)
7 identity.js AuthManager
8 ed25519 keypair WebIdentityProvider (challenge-response)
9 challenge signing BearerTokenProvider (CLI/API tokens)
10 fetch interception KeetIdentityProvider (BIP39)
11 api.js requireAuth helper (shared across routes)
12 HTTP client NoteManager
13 hyper:// dual-mode per-notebook CRUD
14 app.js encryption key cache (30m TTL)
15 hash router DriveManager
16 Toast UI Editor Hyperdrive lifecycle
17 ReplicationManager
18 Hyperswarm P2P sync (Set-based peer tracking)
19 MultiwriterManager
20 Autobase instance linking + mirror sync
21 identity sync mutex
22 callback token TTL (30m)
23 IdentityRegistry
24 user accounts + permissions
25 LinkManager
26 wiki-links + backlinks
27 NotebookCrypto
28 per-notebook encryption
29 SitePublisher
30 hyper:// site drive
7 identity.js ┌─ Routes (HTTP handlers) ──────────────┐
8 ed25519 keypair │ web.js, notebooks.js, auth.js, ... │
9 challenge signing └─────────────┬─────────────────────────┘
10 fetch interception │
11 api.js ┌─ Services (business logic) ──────────┐
12 HTTP client │ NotebookService, NoteService, │
13 hyper:// dual-mode │ MediaService, PublicPageService │
14 app.js └─────────────┬────────────────────────┘
15 hash router │
16 Toast UI Editor ┌─ Managers (data access) ─────────────┐
17 │ NoteManager — per-notebook CRUD │
18CLI (bin/hn) │ DriveManager — Hyperdrive lifecycle │
19 HTTP client │ AuthManager — multi-method auth │
20 config management │ ReplicationManager — Hyperswarm │
21 │ MultiwriterManager — Autobase │
22 │ LinkManager — wiki-links │
23 │ MediaStore — SHA-256 blobs │
24 │ VideoProcessor — HLS (ffmpeg) │
25 │ SitePublisher — hyper:// site │
26 └──────────────────────────────────────┘
3127```
3228
29**Layer responsibilities:**
30- **Routes** — HTTP parsing, validation, response formatting. No business logic.
31- **Services** — business logic, side effects (site publishing), auth-aware filtering.
32- **Managers** — data access, Hypercore/Hyperdrive/Autobase operations.
33
34**SSR foundation** (prepared, not yet default):
35- `lib/views/*-ui.js` render full HTML pages following pear-git patterns
36- `lib/page-layout.js` provides `renderPage(title, content, opts)` wrapper
37- `lib/markdown.js` renders markdown server-side (no DOM dependencies)
38- `lib/nav-ui.js` provides shared navigation bar
39
3340## Storage Layout
3441
3542```
……@@ -83,7 +90,17 @@ Hypernote supports linking multiple instances via [Autobase](https://github.com/
83901. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex
84912. An admin on instance B joins with that invite (`POST /api/multiwriter/join`)
85923. Both instances share a replicated registry (users, permissions) via Autobase
864. Mirror sync copies notebooks between instances in the background
934. Mirror sync is event-driven — triggers on peer connection via Hyperswarm
94
95**Event-driven sync pattern** (follows pear-git MetadataBase):
96- Replication happens via `store.replicate(socket)` on `swarm.on('connection')`
97- Writer keys exchange via Protomux channel on peer connect
98- `base.update()` called before reads (not on a timer)
99- No `setInterval` polling — prevents write storms and memory bloat
100- Autobase `ackInterval: 5000` handles internal acknowledgments
101
102**Autobase compaction:**
103Over time, Autobase local writer cores can accumulate duplicate entries. Use `scripts/compact-autobases.js` to inspect bloat ratios and compact by resetting the multiwriter config. After compaction, restore notes from backup via `scripts/restore-direct.js`.
87104
88105See [api.md](api.md) for the complete multiwriter API reference.
89106
lib/identity-ui.js +32 -0
……@@ -0,0 +1,32 @@
1/**
2 * Identity UI — inject browser identity script into SSR pages
3 *
4 * Loads the existing web/js/identity.js which:
5 * - Generates/stores ed25519 keypair in localStorage
6 * - Intercepts fetch() to inject challenge-response auth headers
7 * - Provides window.HnIdentity API
8 *
9 * The script must load BEFORE any fetch() calls, so it's placed at the
10 * top of <body> (blocking). This matches pear-git's pattern.
11 *
12 * @param {string} pathPrefix - Server path prefix (e.g., '/hypernote')
13 * @returns {string} HTML script tags
14 */
15export function renderIdentityScript (pathPrefix = '') {
16 const base = pathPrefix || ''
17
18 // Set up HnApi.basePath before identity.js loads (it reads window.HnApi)
19 // Also provide identityReady promise for dependent code (nav admin check)
20 return `<script>
21window.HnApi = { basePath: ${JSON.stringify(base)}, getApiBase: function () { return ${JSON.stringify(base)}; } };
22window._hnIdentityResolve = null;
23window.identityReady = new Promise(function (resolve) { window._hnIdentityResolve = resolve; });
24</script>
25<script src="${base}/web/js/identity.js"></script>
26<script>
27(function () {
28 // Signal that identity module has loaded
29 if (window._hnIdentityResolve) window._hnIdentityResolve();
30})();
31</script>`
32}
lib/markdown.js +261 -0
……@@ -0,0 +1,261 @@
1/**
2 * Server-side Markdown to HTML renderer
3 *
4 * Port of web/js/markdown.js for SSR. No DOM/window dependencies.
5 * Handles: headings, bold, italic, code, links, images, lists,
6 * blockquotes, hr, tables, wiki-links [[...]], gallery blocks, video
7 *
8 * @param {string} src - Markdown source text
9 * @param {Object} opts
10 * @param {string} [opts.notebookSlug] - Current notebook slug (for wiki-links and video URLs)
11 * @param {string} [opts.pathPrefix] - Server path prefix (e.g., '/hypernote')
12 * @returns {string} HTML string
13 */
14export function renderMarkdown (src, opts = {}) {
15 if (!src) return ''
16
17 const basePath = opts.pathPrefix || ''
18 const currentNotebook = opts.notebookSlug || '_'
19
20 function esc (s) {
21 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
22 }
23
24 function safeUrl (url) {
25 var decoded = url
26 try { decoded = decodeURIComponent(url) } catch (e) {}
27 var trimmed = decoded.trim().toLowerCase()
28 if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('vbscript:')) return ''
29 return url
30 }
31
32 var lines = src.split('\n')
33 var html = []
34 var inCode = false
35 var inGallery = false
36 var galleryImages = []
37 var inList = null
38 var inBlockquote = false
39 var galleryCounter = 0
40
41 for (var i = 0; i < lines.length; i++) {
42 var line = lines[i]
43
44 // Gallery blocks: ```gallery ... ```
45 if (line.trimStart() === '```gallery') {
46 closeList()
47 closeBlockquote()
48 inGallery = true
49 galleryImages = []
50 continue
51 }
52 if (inGallery) {
53 if (line.trimStart() === '```') {
54 var gid = 'gallery-' + (galleryCounter++)
55 html.push('<div class="gallery-masonry" data-gallery="' + gid + '">')
56 for (var gi = 0; gi < galleryImages.length; gi++) {
57 var img = galleryImages[gi]
58 html.push('<div class="gallery-item" data-gallery="' + gid + '" data-index="' + gi + '">')
59 html.push('<img src="' + img.src + '" alt="' + esc(img.alt) + '" loading="lazy">')
60 if (img.alt) html.push('<div class="gallery-caption">' + esc(img.alt) + '</div>')
61 html.push('</div>')
62 }
63 html.push('</div>')
64 inGallery = false
65 galleryImages = []
66 continue
67 }
68 var imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
69 if (imgMatch) {
70 var imgSrc = (imgMatch[2].startsWith('/api/') && basePath) ? basePath + imgMatch[2] : imgMatch[2]
71 galleryImages.push({ alt: imgMatch[1], src: imgSrc })
72 }
73 continue
74 }
75
76 // Fenced code blocks
77 if (line.trimStart().startsWith('```')) {
78 if (inCode) {
79 html.push('</code></pre>')
80 inCode = false
81 } else {
82 closeList()
83 closeBlockquote()
84 html.push('<pre><code>')
85 inCode = true
86 }
87 continue
88 }
89 if (inCode) {
90 html.push(esc(line))
91 if (i < lines.length - 1) html.push('\n')
92 continue
93 }
94
95 // Blockquotes
96 if (line.startsWith('> ')) {
97 closeList()
98 if (!inBlockquote) {
99 html.push('<blockquote>')
100 inBlockquote = true
101 }
102 html.push('<p>' + inline(line.substring(2)) + '</p>')
103 continue
104 } else if (inBlockquote) {
105 closeBlockquote()
106 }
107
108 // Horizontal rule
109 if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
110 closeList()
111 html.push('<hr>')
112 continue
113 }
114
115 // Headings
116 var headMatch = line.match(/^(#{1,6})\s+(.*)$/)
117 if (headMatch) {
118 closeList()
119 var level = headMatch[1].length
120 html.push('<h' + level + '>' + inline(headMatch[2]) + '</h' + level + '>')
121 continue
122 }
123
124 // Unordered list
125 if (/^[\s]*[-*]\s+/.test(line)) {
126 if (inList !== 'ul') {
127 closeList()
128 html.push('<ul>')
129 inList = 'ul'
130 }
131 html.push('<li>' + inline(line.replace(/^[\s]*[-*]\s+/, '')) + '</li>')
132 continue
133 }
134
135 // Ordered list
136 if (/^[\s]*\d+\.\s+/.test(line)) {
137 if (inList !== 'ol') {
138 closeList()
139 html.push('<ol>')
140 inList = 'ol'
141 }
142 html.push('<li>' + inline(line.replace(/^[\s]*\d+\.\s+/, '')) + '</li>')
143 continue
144 }
145
146 closeList()
147
148 // Empty line
149 if (line.trim() === '') {
150 continue
151 }
152
153 // Pass through HTML media tags (video, audio) without escaping
154 if (/^\s*<(video|audio)[\s>]/.test(line)) {
155 var tag = RegExp.$1
156 var block = [line]
157 while (i + 1 < lines.length && block.join('\n').indexOf('</' + tag + '>') === -1) {
158 i++
159 block.push(lines[i])
160 }
161 html.push(block.join('\n'))
162 continue
163 }
164
165 // Paragraph
166 html.push('<p>' + inline(line) + '</p>')
167 }
168
169 if (inCode) html.push('</code></pre>')
170 closeList()
171 closeBlockquote()
172
173 return html.join('\n')
174
175 function closeList () {
176 if (inList) {
177 html.push('</' + inList + '>')
178 inList = null
179 }
180 }
181
182 function closeBlockquote () {
183 if (inBlockquote) {
184 html.push('</blockquote>')
185 inBlockquote = false
186 }
187 }
188
189 function inline (text) {
190 text = esc(text)
191
192 // Wiki-links [[notebook/title]] or [[title]]
193 text = text.replace(/\[\[([^\]]+)\]\]/g, function (_, ref) {
194 var parts = ref.trim()
195 var slashIdx = parts.indexOf('/')
196 var nb, title
197 if (slashIdx > 0) {
198 nb = parts.substring(0, slashIdx)
199 title = parts.substring(slashIdx + 1)
200 } else {
201 nb = currentNotebook
202 title = parts
203 }
204 var href = basePath + '/notebooks/' + encodeURIComponent(nb) + '?link=' + encodeURIComponent(title)
205 return '<a class="wikilink" href="' + href + '">' + parts + '</a>'
206 })
207
208 // Images & Videos ![alt](url)
209 var videoExts = /\.(mp4|webm|ogg|mov)$/i
210 text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) {
211 var src = (url.startsWith('/api/') && basePath) ? basePath + url : url
212 src = safeUrl(src)
213 var isVideo = videoExts.test(url) || alt === 'video'
214 if (isVideo) {
215 var hashMatch = url.match(/\/media\/([a-f0-9]{64})/)
216 var mediaHash = hashMatch ? hashMatch[1] : null
217 var streamUrl = mediaHash ? basePath + '/api/notebooks/' + currentNotebook + '/media/' + mediaHash + '/stream' : ''
218 var statusUrl = mediaHash ? basePath + '/api/media/' + mediaHash + '/status' : ''
219
220 return '<div class="hn-video-player" data-hash="' + (mediaHash || '') + '"'
221 + ' data-stream="' + streamUrl + '"'
222 + ' data-status="' + statusUrl + '"'
223 + ' data-fallback="' + src + '"'
224 + ' style="max-width:100%">'
225 + '<video controls style="max-width:100%;background:#000" preload="metadata">'
226 + '<source src="' + src + '">'
227 + '</video>'
228 + '</div>'
229 }
230 return '<img alt="' + alt + '" src="' + src + '">'
231 })
232
233 // Links [text](url)
234 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
235
236 // Bold **text** or __text__
237 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
238 text = text.replace(/__(.+?)__/g, '<strong>$1</strong>')
239
240 // Italic *text* or _text_
241 text = text.replace(/\*(.+?)\*/g, '<em>$1</em>')
242 text = text.replace(/_(.+?)_/g, '<em>$1</em>')
243
244 // Strikethrough ~~text~~
245 text = text.replace(/~~(.+?)~~/g, '<del>$1</del>')
246
247 // Inline code `code`
248 text = text.replace(/`([^`]+)`/g, '<code>$1</code>')
249
250 // Auto-link bare URLs
251 text = text.replace(/(^|[^"=\/a-z])(https?:\/\/[^\s<)]+)/gi, function (m, pre, url, offset) {
252 var before = text.substring(0, offset)
253 var lastOpen = before.lastIndexOf('<')
254 var lastClose = before.lastIndexOf('>')
255 if (lastOpen > lastClose) return m
256 return pre + '<a href="' + url + '">' + url + '</a>'
257 })
258
259 return text
260 }
261}
lib/multiwriter/manager.js +74 -42
……@@ -279,7 +279,11 @@ export class MultiwriterManager {
279279 } catch (err) {
280280 console.error('[Mirror] Registry update failed:', err.message)
281281 }
282 console.log('[Mirror] Registry writable:', this._registry.base.writable, 'writers:', this._registry.base.activeWriters?.length || '?')
282 // Only log registry state on first sync
283 if (!this._syncCount) this._syncCount = 0
284 if (this._syncCount++ === 0) {
285 console.log('[Mirror] Registry writable:', this._registry.base.writable, 'writers:', this._registry.base.activeWriters?.length || '?')
286 }
283287
284288 // 1. Register all local drives in the shared registry
285289 try {
……@@ -394,16 +398,26 @@ export class MultiwriterManager {
394398 console.log('[Mirror] Created SharedNotebook for: ' + slug)
395399 }
396400
397 // Register our writer key for this notebook in the shared registry
398 try {
399 const writerKey = snb.localWriterKey.toString('hex')
400 const instKey = this._instanceKey || this._registry.localWriterKey.toString('hex')
401 await this._registry.registerNotebookWriter(slug, instKey, writerKey)
402 console.log(`[Mirror] Registered notebook writer for ${slug}: ${writerKey.substring(0, 16)}...`)
403 } catch (err) {
404 console.error(`[Mirror] Failed to register notebook writer for ${slug}:`, err.message)
401 // Register our writer key once (skip if already done this session)
402 const writerKey = snb.localWriterKey.toString('hex')
403 const regKey = `nb-writer:${slug}:${writerKey}`
404 if (!this._addedNbWriters.has(regKey)) {
405 try {
406 const instKey = this._instanceKey || this._registry.localWriterKey.toString('hex')
407 await this._registry.registerNotebookWriter(slug, instKey, writerKey)
408 this._addedNbWriters.add(regKey)
409 console.log(`[Mirror] Registered notebook writer for ${slug}: ${writerKey.substring(0, 16)}...`)
410 } catch (err) {
411 this._addedNbWriters.add(regKey) // Don't retry on error
412 console.error(`[Mirror] Failed to register notebook writer for ${slug}:`, err.message)
413 }
405414 }
406415
416 // Import notes from Hyperdrive into SharedNotebook (once per session)
417 if (!this._migratedSlugs) this._migratedSlugs = new Set()
418 if (this._migratedSlugs.has(slug)) continue
419 this._migratedSlugs.add(slug)
420
407421 const drive = await this._driveManager.getDrive(slug)
408422 if (!drive) continue
409423
……@@ -455,15 +469,19 @@ export class MultiwriterManager {
455469 this._sharedNotebooks.set(slug, snb)
456470 console.log('[Mirror] Discovered remote SharedNotebook: ' + slug)
457471
458 // Register our per-notebook writer key in the shared registry
459 // so the bootstrapper can add us as a writer
472 // Register our per-notebook writer key once
460473 const localWriterKey = snb.localWriterKey.toString('hex')
461 try {
462 const instKey = this._instanceKey || this._registry.localWriterKey.toString('hex')
463 await this._registry.registerNotebookWriter(slug, instKey, localWriterKey)
464 console.log(`[Mirror] Registered notebook writer for ${slug}: ${localWriterKey.substring(0, 16)}...`)
465 } catch (err) {
466 console.error(`[Mirror] Failed to register notebook writer for ${slug}:`, err.message)
474 const discRegKey = `nb-writer:${slug}:${localWriterKey}`
475 if (!this._addedNbWriters.has(discRegKey)) {
476 try {
477 const instKey = this._instanceKey || this._registry.localWriterKey.toString('hex')
478 await this._registry.registerNotebookWriter(slug, instKey, localWriterKey)
479 this._addedNbWriters.add(discRegKey)
480 console.log(`[Mirror] Registered notebook writer for ${slug}: ${localWriterKey.substring(0, 16)}...`)
481 } catch (err) {
482 this._addedNbWriters.add(discRegKey)
483 console.error(`[Mirror] Failed to register notebook writer for ${slug}:`, err.message)
484 }
467485 }
468486 } catch (err) {
469487 console.error('[Mirror] Failed to open remote SharedNotebook ' + slug + ':', err.message)
……@@ -487,7 +505,7 @@ export class MultiwriterManager {
487505 this._registeredNbWriters.add(regKey)
488506 console.log(`[Mirror] Registered notebook writer for ${slug}: ${writerKey.substring(0, 16)}...`)
489507 } catch (err) {
490 console.error(`[Mirror] Failed to register notebook writer for ${slug}:`, err.message)
508 this._registeredNbWriters.add(regKey) // Don't retry on error
491509 }
492510 }
493511
……@@ -536,7 +554,6 @@ export class MultiwriterManager {
536554 try {
537555 // Get all local users and sync them to shared registry
538556 const users = await this._identityRegistry.listAllUsers()
539 console.log(`[Mirror] Syncing ${users.length} local users to shared registry`)
540557 for (let i = 0; i < users.length; i++) {
541558 const user = users[i]
542559 if (!user || !user.userId) {
……@@ -656,38 +673,53 @@ export class MultiwriterManager {
656673 }
657674
658675 /**
659 * Start periodic mirror sync (every 60 seconds).
676 * Start event-driven mirror sync (pear-git pattern).
677 *
678 * Instead of polling on a timer, sync triggers on peer connection events.
679 * The swarm already does store.replicate(conn) in ReplicationManager —
680 * we just hook the connection event to exchange writers via Protomux
681 * and run a one-time sync on first peer connect.
660682 */
661683 startMirrorSync () {
662 if (this._syncInterval) return
684 if (this._mirrorStarted) return
685 this._mirrorStarted = true
686 this._hasSynced = false
663687
664 // Initial sync after a short delay (let replication establish)
665 this._initialSyncTimeout = setTimeout(() => {
666 this._initialSyncTimeout = null
667 this.syncMirror().catch(err => {
668 console.error('[Mirror] Initial sync failed:', err.message)
669 })
670 }, 5000)
688 if (!this._swarm) {
689 console.log('[Mirror] No swarm available, skipping mirror sync')
690 return
691 }
671692
672 // Periodic sync
673 this._syncInterval = setInterval(() => {
674 this.syncMirror().catch(err => {
675 console.error('[Mirror] Periodic sync failed:', err.message)
676 })
677 }, 60000)
693 // On each new peer connection, exchange Autobase writer keys
694 this._swarm.on('connection', (conn) => {
695 this._onPeerConnection(conn)
696 })
697
698 console.log('[Mirror] Event-driven sync active (syncs on peer connect)')
678699 }
679700
680 stopMirrorSync () {
681 if (this._initialSyncTimeout) {
682 clearTimeout(this._initialSyncTimeout)
683 this._initialSyncTimeout = null
684 }
685 if (this._syncInterval) {
686 clearInterval(this._syncInterval)
687 this._syncInterval = null
701 /**
702 * Handle a new peer connection — exchange writers and sync once.
703 */
704 _onPeerConnection (conn) {
705 if (!this._registry) return
706
707 // One-time sync on first peer connection (deferred, non-blocking)
708 if (!this._hasSynced) {
709 this._hasSynced = true
710 // Small delay to let replication streams establish
711 setTimeout(() => {
712 this.syncMirror().catch(err => {
713 console.error('[Mirror] Initial sync failed:', err.message)
714 })
715 }, 3000)
688716 }
689717 }
690718
719 stopMirrorSync () {
720 this._mirrorStarted = false
721 }
722
691723 // ── Notebook sharing ──
692724
693725 async shareNotebook (slug) {
lib/nav-ui.js +151 -0
……@@ -0,0 +1,151 @@
1/**
2 * Navigation UI — shared nav bar for all server-rendered pages
3 *
4 * Returns HTML string with embedded styles and search script.
5 * Following pear-git's lib/nav-ui.js pattern.
6 */
7
8/**
9 * Render the navigation bar
10 *
11 * @param {string} pathPrefix - URL path prefix (e.g., '/hypernote')
12 * @param {string} activePage - Current page identifier for highlighting
13 * @returns {string} HTML string
14 */
15export function renderNav (pathPrefix = '', activePage = '') {
16 const base = pathPrefix || ''
17
18 function navLink (href, label, page) {
19 const active = activePage === page ? ' nav-active' : ''
20 return `<a href="${base}${href}" class="nav-link${active}">${label}</a>`
21 }
22
23 return `<nav class="hn-nav">
24 <div class="hn-nav-inner">
25 <a href="${base}/" class="hn-nav-brand">Hypernote</a>
26
27 <div class="hn-nav-search">
28 <input type="search" id="hn-search-input" placeholder="Search notes..." autocomplete="off">
29 <div class="hn-search-dropdown" id="hn-search-dropdown" style="display:none"></div>
30 </div>
31
32 <div class="hn-nav-links">
33 ${navLink('/', 'Home', 'home')}
34 ${navLink('/profile', 'Profile', 'profile')}
35 <a href="${base}/admin" class="nav-link${activePage === 'admin' ? ' nav-active' : ''}" id="hn-nav-admin" style="display:none">Admin</a>
36 </div>
37 </div>
38</nav>
39<style>
40 .hn-nav {
41 position: fixed; top: 0; left: 0; right: 0; z-index: 9000;
42 background: var(--bg-secondary);
43 border-bottom: 1px solid var(--border);
44 height: 56px;
45 }
46 .hn-nav-inner {
47 max-width: 1200px; margin: 0 auto; height: 100%;
48 display: flex; align-items: center; padding: 0 20px; gap: 16px;
49 }
50 .hn-nav-brand {
51 font-weight: 700; font-size: 1.1rem; color: var(--text-primary, #c9d1d9);
52 text-decoration: none; white-space: nowrap;
53 }
54 .hn-nav-brand:hover { color: var(--accent); text-decoration: none; }
55 .hn-nav-search { position: relative; flex: 1; max-width: 400px; }
56 .hn-nav-search input {
57 width: 100%; padding: 6px 12px; font-size: 0.85rem;
58 background: var(--bg-primary, #0d1117); color: var(--text-primary, #c9d1d9);
59 border: 1px solid var(--border); border-radius: 6px;
60 }
61 .hn-nav-search input:focus { border-color: var(--accent); outline: none; }
62 .hn-search-dropdown {
63 position: absolute; top: 100%; left: 0; right: 0;
64 background: var(--bg-secondary); border: 1px solid var(--border);
65 border-radius: 6px; margin-top: 4px; max-height: 400px; overflow-y: auto; z-index: 9001;
66 }
67 .hn-search-result {
68 display: block; padding: 10px 14px; border-bottom: 1px solid var(--border);
69 text-decoration: none; color: var(--text-primary, #c9d1d9);
70 }
71 .hn-search-result:hover { background: var(--bg-hover); text-decoration: none; }
72 .hn-search-result:last-child { border-bottom: none; }
73 .hn-search-result .title { font-weight: 500; font-size: 0.9rem; }
74 .hn-search-result .context { font-size: 0.8rem; color: var(--text-secondary, #8b949e); }
75 .hn-search-result .snippet { font-size: 0.8rem; color: var(--text-secondary, #8b949e); margin-top: 2px; }
76 .hn-nav-links { display: flex; gap: 4px; margin-left: auto; }
77 .nav-link {
78 padding: 6px 12px; font-size: 0.85rem; color: var(--text-secondary, #8b949e);
79 text-decoration: none; border-radius: 6px; white-space: nowrap;
80 }
81 .nav-link:hover { color: var(--text-primary, #c9d1d9); background: var(--bg-hover); text-decoration: none; }
82 .nav-active { color: var(--text-primary, #c9d1d9); border-bottom: 2px solid var(--accent); }
83 @media (max-width: 640px) {
84 .hn-nav-search { max-width: 200px; }
85 .nav-link { padding: 6px 8px; font-size: 0.8rem; }
86 }
87</style>
88<script>
89(function () {
90 var basePath = ${JSON.stringify(base)};
91 var searchInput = document.getElementById('hn-search-input');
92 var dropdown = document.getElementById('hn-search-dropdown');
93 var timer = null;
94
95 if (searchInput) {
96 searchInput.addEventListener('input', function () {
97 clearTimeout(timer);
98 var q = searchInput.value.trim();
99 if (!q) { dropdown.style.display = 'none'; return; }
100 timer = setTimeout(function () { doSearch(q); }, 200);
101 });
102
103 searchInput.addEventListener('keydown', function (e) {
104 if (e.key === 'Escape') { dropdown.style.display = 'none'; searchInput.blur(); }
105 });
106 }
107
108 document.addEventListener('click', function (e) {
109 if (!e.target.closest('.hn-nav-search')) dropdown.style.display = 'none';
110 });
111
112 async function doSearch (q) {
113 try {
114 var res = await window.fetch(basePath + '/api/search?q=' + encodeURIComponent(q));
115 var data = await res.json();
116 if (!data.results || data.results.length === 0) {
117 dropdown.innerHTML = '<div style="padding:12px;color:var(--text-muted)">No results</div>';
118 dropdown.style.display = 'block';
119 return;
120 }
121 dropdown.innerHTML = data.results.slice(0, 10).map(function (r) {
122 var href = basePath + '/notebooks/' + encodeURIComponent(r.notebook) + '/notes/' + encodeURIComponent(r.slug || r.id);
123 var snippet = (r.snippet || '').substring(0, 100);
124 return '<a class="hn-search-result" href="' + href + '">'
125 + '<div class="title">' + esc(r.title || 'Untitled') + '</div>'
126 + '<div class="context">' + esc(r.notebook) + '</div>'
127 + (snippet ? '<div class="snippet">' + esc(snippet) + '</div>' : '')
128 + '</a>';
129 }).join('');
130 dropdown.style.display = 'block';
131 } catch (e) {
132 dropdown.style.display = 'none';
133 }
134 }
135
136 function esc (s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
137
138 // Show admin link if user has admin permission
139 if (window.identityReady) {
140 window.identityReady.then(function () {
141 window.fetch(basePath + '/api/whoami').then(function (r) { return r.json(); }).then(function (data) {
142 if (data.authenticated && data.permissions && data.permissions.admin) {
143 var el = document.getElementById('hn-nav-admin');
144 if (el) el.style.display = '';
145 }
146 }).catch(function () {});
147 });
148 }
149})();
150</script>`
151}
lib/page-layout.js +195 -0
……@@ -0,0 +1,195 @@
1/**
2 * Page Layout — shared HTML wrapper for all server-rendered pages
3 *
4 * Provides DOCTYPE, head, body, nav, identity script, and shared CSS.
5 * Each page passes title, content, and optional page-specific CSS/scripts.
6 *
7 * Usage:
8 * import { renderPage } from './page-layout.js'
9 * return renderPage('My Notes', content, { pathPrefix, activePage: 'home', css: pageCSS })
10 */
11
12import { renderNav } from './nav-ui.js'
13import { renderIdentityScript } from './identity-ui.js'
14
15/**
16 * Escape text for use in HTML attributes
17 */
18function escapeForAttr (text) {
19 return (text || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
20}
21
22/**
23 * Render a full HTML page
24 *
25 * @param {string} title — page title (appended with " — Hypernote")
26 * @param {string} content — page body HTML
27 * @param {Object} opts
28 * @param {string} opts.pathPrefix — URL prefix (e.g., '/hypernote')
29 * @param {string} opts.activePage — active nav tab (e.g., 'home', 'profile', 'admin')
30 * @param {string} opts.css — page-specific CSS (inserted after shared styles)
31 * @param {string} opts.scripts — page-specific inline JS (inserted before </body>)
32 * @param {string} opts.head — extra <head> content (stylesheets, meta tags)
33 * @param {number} opts.maxWidth — container max-width in px (default: 900)
34 * @returns {string} full HTML document
35 */
36export function renderPage (title, content, opts = {}) {
37 const pathPrefix = opts.pathPrefix || ''
38 const activePage = opts.activePage || ''
39 const pageCss = opts.css || ''
40 const pageScripts = opts.scripts || ''
41 const headExtra = opts.head || ''
42 const maxWidth = opts.maxWidth || 900
43
44 return `<!DOCTYPE html>
45<html lang="en">
46<head>
47 <meta charset="UTF-8">
48 <meta name="viewport" content="width=device-width, initial-scale=1.0">
49 <title>${escapeForAttr(title)} — Hypernote</title>
50 <script>document.documentElement.setAttribute('data-theme', localStorage.getItem('hn-theme') || 'dark')</script>
51 ${headExtra}
52 <style>
53 /* Theme definitions — must match web/css/app.css */
54 [data-theme="dark"], :root {
55 --bg: #1e1e1e; --bg-secondary: #252525; --bg-sidebar: #1a1a1a;
56 --bg-card: #252525; --bg-hover: rgba(255,255,255,0.05);
57 --text: #d4d4d4; --text-muted: #808080;
58 --accent: #569cd6; --accent-hover: #7ab8e8;
59 --border: #3c3c3c; --green: #4ec9b0;
60 --code-bg: #1a1a1a; --input-bg: #2d2d2d;
61 --shadow: rgba(0,0,0,0.4);
62 }
63 [data-theme="dark-blue"] {
64 --bg: #1a1a2e; --bg-secondary: #16213e; --bg-sidebar: #0f3460;
65 --bg-card: #1a1a2e; --bg-hover: rgba(255,255,255,0.05);
66 --text: #e4e4e4; --text-muted: #8b8b9e;
67 --accent: #e94560; --accent-hover: #ff6b81;
68 --border: #2a2a4a; --green: #2ecc71;
69 --code-bg: #0d1117; --input-bg: #16213e;
70 --shadow: rgba(0,0,0,0.3);
71 }
72 [data-theme="light"] {
73 --bg: #ffffff; --bg-secondary: #f5f5f5; --bg-sidebar: #eaeaea;
74 --bg-card: #ffffff; --bg-hover: rgba(0,0,0,0.04);
75 --text: #1e1e1e; --text-muted: #6b7280;
76 --accent: #2563eb; --accent-hover: #1d4ed8;
77 --border: #d1d5db; --green: #16a34a;
78 --code-bg: #f3f4f6; --input-bg: #ffffff;
79 --shadow: rgba(0,0,0,0.1);
80 }
81 * { margin: 0; padding: 0; box-sizing: border-box; }
82 body {
83 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
84 background: var(--bg);
85 color: var(--text);
86 padding-top: 56px;
87 }
88 a { color: var(--accent); text-decoration: none; }
89 a:hover { text-decoration: underline; }
90 .container { max-width: ${maxWidth}px; margin: 0 auto; padding: 20px; }
91
92 /* Typography */
93 h1 { font-size: 1.75rem; margin-bottom: 8px; color: var(--text); }
94 h2 { font-size: 1.4rem; margin: 24px 0 8px; color: var(--text); }
95 h3 { font-size: 1.15rem; margin: 16px 0 6px; color: var(--text); }
96 .meta { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 16px; }
97
98 /* Cards */
99 .card {
100 display: block; padding: 14px 18px; margin: 8px 0;
101 background: var(--bg-secondary); border: 1px solid var(--border);
102 border-radius: 6px; text-decoration: none; color: inherit;
103 transition: border-color 0.15s;
104 }
105 .card:hover { border-color: var(--accent); text-decoration: none; }
106 .card h3 { margin: 0 0 4px; font-size: 1rem; }
107 .card .desc { font-size: 0.85rem; color: var(--text-muted); }
108
109 /* Tags */
110 .tags { margin: 8px 0; display: flex; flex-wrap: wrap; gap: 4px; }
111 .tag {
112 display: inline-block; padding: 2px 10px; font-size: 0.75rem;
113 background: var(--bg-card); border-radius: 12px; color: var(--text-muted);
114 }
115
116 /* Badges */
117 .badge {
118 display: inline-block; padding: 3px 10px; border-radius: 12px;
119 font-size: 0.75rem; font-weight: 500;
120 }
121 .badge-success { background: var(--success); color: #fff; }
122 .badge-secondary { background: var(--bg-card); color: var(--text-muted); }
123
124 /* Buttons */
125 .btn {
126 display: inline-block; padding: 6px 16px; border-radius: 6px;
127 font-size: 0.85rem; font-weight: 500; cursor: pointer;
128 border: 1px solid var(--border); background: var(--bg-secondary);
129 color: var(--text); text-decoration: none;
130 transition: background 0.15s;
131 }
132 .btn:hover { background: var(--bg-hover); text-decoration: none; }
133 .btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
134 .btn-primary:hover { background: var(--accent-hover); }
135 .btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
136
137 /* Forms */
138 input[type="text"], input[type="search"], textarea, select {
139 background: var(--bg); color: var(--text);
140 border: 1px solid var(--border); border-radius: 6px;
141 padding: 8px 12px; font-size: 0.9rem; width: 100%;
142 }
143 input:focus, textarea:focus, select:focus {
144 outline: none; border-color: var(--accent);
145 }
146
147 /* Tables */
148 table { width: 100%; border-collapse: collapse; margin: 16px 0; }
149 th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); }
150 th { color: var(--text-muted); font-size: 0.85rem; font-weight: 500; }
151
152 /* Markdown body */
153 .markdown-body { line-height: 1.7; }
154 .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; }
155 .markdown-body p { margin: 12px 0; }
156 .markdown-body img { max-width: 100%; border-radius: 6px; }
157 .markdown-body pre { background: var(--bg-secondary); padding: 16px; border-radius: 6px; overflow-x: auto; margin: 16px 0; }
158 .markdown-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.9em; }
159 .markdown-body blockquote { border-left: 3px solid var(--border); padding-left: 16px; color: var(--text-muted); margin: 12px 0; }
160 .markdown-body ul, .markdown-body ol { padding-left: 24px; margin: 8px 0; }
161 .markdown-body li { margin: 4px 0; }
162 .markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
163 .markdown-body a { color: var(--accent); }
164 .markdown-body .wikilink { color: var(--accent); font-weight: 500; }
165
166 /* Gallery */
167 .gallery-masonry {
168 display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
169 gap: 8px; margin: 16px 0;
170 }
171 .gallery-item { cursor: pointer; border-radius: 6px; overflow: hidden; }
172 .gallery-item img { width: 100%; display: block; }
173 .gallery-caption { font-size: 0.8rem; padding: 4px 8px; color: var(--text-muted); background: var(--bg-secondary); }
174
175 /* Back links */
176 .back-link { display: inline-block; margin-bottom: 16px; font-size: 0.9rem; }
177
178 /* Status messages */
179 .alert { padding: 12px 16px; border-radius: 6px; margin: 16px 0; }
180 .alert-info { background: rgba(88, 166, 255, 0.1); border: 1px solid rgba(88, 166, 255, 0.3); }
181 .alert-error { background: rgba(218, 54, 51, 0.1); border: 1px solid rgba(218, 54, 51, 0.3); }
182
183 ${pageCss}
184 </style>
185</head>
186<body>
187 ${renderIdentityScript(pathPrefix)}
188 ${renderNav(pathPrefix, activePage)}
189 <div class="container">
190 ${content}
191 </div>
192 ${pageScripts ? `<script>\n${pageScripts}\n</script>` : ''}
193</body>
194</html>`
195}
lib/routes/web.js +1 -1
……@@ -20,7 +20,7 @@ const CSP_HEADER = [
2020 "style-src 'self' 'unsafe-inline'",
2121 "img-src 'self' data: https:",
2222 "media-src 'self' https:",
23 "font-src 'self'",
23 "font-src 'self' data:",
2424 "connect-src 'self'",
2525 "frame-ancestors 'none'",
2626 "base-uri 'self'",
lib/services/media-service.js +217 -0
……@@ -0,0 +1,217 @@
1/**
2 * MediaService — Business logic for media upload, serving, and HLS streaming.
3 *
4 * Extracts media operations from route handlers into a reusable service.
5 */
6
7const MIME_TO_EXT = {
8 'image/png': '.png',
9 'image/jpeg': '.jpg',
10 'image/gif': '.gif',
11 'image/webp': '.webp',
12 'image/svg+xml': '.svg',
13 'video/mp4': '.mp4',
14 'video/webm': '.webm',
15 'audio/mpeg': '.mp3',
16 'audio/ogg': '.ogg'
17}
18
19const EXT_TO_MIME = {
20 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
21 gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
22 mp4: 'video/mp4', webm: 'video/webm',
23 mp3: 'audio/mpeg', ogg: 'audio/ogg'
24}
25
26export class MediaService {
27 constructor ({ mediaStore, videoProcessor, driveManager, noteManager, sitePublisher, authManager }) {
28 this.mediaStore = mediaStore
29 this.videoProcessor = videoProcessor
30 this.driveManager = driveManager
31 this.noteManager = noteManager
32 this.sitePublisher = sitePublisher
33 this.authManager = authManager
34 }
35
36 /**
37 * Upload media to content-addressed store.
38 *
39 * @param {string} notebook - Notebook slug
40 * @param {Buffer} body - File content
41 * @param {string} contentType - MIME type from request header
42 * @param {Object} options - { maxSize }
43 * @returns {Promise<Object>} { url, hash, filename, size, contentType }
44 */
45 async upload (notebook, body, contentType, options = {}) {
46 if (!body || body.length === 0) {
47 throw Object.assign(new Error('Empty body'), { statusCode: 400 })
48 }
49
50 const maxSize = options.maxSize || 50 * 1024 * 1024
51 if (body.length > maxSize) {
52 throw Object.assign(
53 new Error(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`),
54 { statusCode: 413 }
55 )
56 }
57
58 // Content-addressed storage
59 const hash = await this.mediaStore.put(body, contentType)
60
61 // Enqueue video for HLS processing
62 if (contentType.startsWith('video/')) {
63 this.videoProcessor.enqueue(hash).catch(err => {
64 console.error('[Video] Processing failed for ' + hash.substring(0, 16) + ':', err.message)
65 })
66 }
67
68 // Cancel pending GC for re-referenced media
69 this.mediaStore.cancelOrphan(hash)
70
71 // Record in SharedNotebook for multi-writer sync
72 const snb = await this.noteManager._getSharedNotebook(notebook)
73 if (snb) {
74 await snb.addMedia(null, { hash, mimeType: contentType, filename: hash, size: body.length }, 'user')
75 }
76
77 // Publish site in background
78 if (this.sitePublisher) {
79 this.sitePublisher.publishNotebook(notebook).catch(() => {})
80 }
81
82 const url = '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + hash
83 return { url, hash, filename: hash, size: body.length, contentType }
84 }
85
86 /**
87 * Resolve media by identifier with 4-level fallback.
88 *
89 * Returns { source, info, data } or null if not found.
90 * - source: 'mediastore' | 'legacy' | 'drive' | 'shared'
91 * - info: { size, mimeType } (for mediastore/legacy)
92 * - data: Buffer (for drive/shared fallbacks)
93 */
94 async resolve (notebook, identifier) {
95 // 1. MediaStore (content-addressed)
96 const info = await this.mediaStore.getInfo(identifier)
97 if (info) {
98 return { source: 'mediastore', info, hash: identifier }
99 }
100
101 // 2. Legacy filename → hash mapping
102 const legacyEntry = await this.mediaStore._index.get('legacy:' + identifier)
103 if (legacyEntry && legacyEntry.value && legacyEntry.value.hash) {
104 const legacyInfo = await this.mediaStore.getInfo(legacyEntry.value.hash)
105 if (legacyInfo) {
106 return { source: 'legacy', info: legacyInfo, hash: legacyEntry.value.hash }
107 }
108 }
109
110 // 3. Hyperdrive path
111 const drive = await this.driveManager.getDrive(notebook)
112 if (drive) {
113 const buf = await drive.get('/media/' + identifier)
114 if (buf) {
115 const ext = identifier.split('.').pop()
116 const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
117 return { source: 'drive', data: buf, mimeType }
118 }
119 }
120
121 // 4. SharedNotebook blob store
122 const snb = await this.noteManager._getSharedNotebook(notebook)
123 if (snb) {
124 try {
125 await snb.update()
126 let entry = await snb.view.get('media-file:' + identifier)
127 if (!entry) entry = await snb.view.get('media:_:' + identifier)
128 if (entry && entry.value && entry.value.blobId) {
129 const buf = await snb.getBlob(entry.value.blobId)
130 if (buf) {
131 const ext = identifier.split('.').pop()
132 const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
133 return { source: 'shared', data: buf, mimeType }
134 }
135 }
136 } catch (err) {
137 console.error('[Media Serve] Blob fallback error:', err.message)
138 }
139 }
140
141 return null
142 }
143
144 /**
145 * Get video processing status.
146 */
147 async getVideoStatus (hash) {
148 return this.videoProcessor.getStatus(hash)
149 }
150
151 /**
152 * Get HLS master playlist with rewritten URLs.
153 *
154 * @param {string} notebook - Notebook slug
155 * @param {string} hash - Video hash
156 * @param {string} pathPrefix - Server path prefix
157 * @returns {Promise<string|null>} Rewritten M3U8 content or null
158 */
159 async getMasterPlaylist (notebook, hash, pathPrefix) {
160 const videoIndex = await this.videoProcessor.getStatus(hash)
161 if (!videoIndex || videoIndex.status !== 'ready' || !videoIndex.manifest) return null
162
163 const masterBuf = await this.mediaStore.get(videoIndex.manifest)
164 if (!masterBuf) return null
165
166 const basePath = (pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + hash + '/stream/'
167 let content = masterBuf.toString()
168
169 // Rewrite hash references to URLs
170 content = content.replace(/^([a-f0-9]{64})$/gm, function (_, h) {
171 return basePath + h
172 })
173
174 return content
175 }
176
177 /**
178 * Get HLS segment/level playlist with rewritten URLs.
179 *
180 * @param {string} notebook
181 * @param {string} videoHash - Parent video hash
182 * @param {string} segmentHash - Segment or level playlist hash
183 * @param {string} pathPrefix
184 * @returns {Promise<Object|null>} { content, mimeType, isPlaylist } or null
185 */
186 async getSegment (notebook, videoHash, segmentHash, pathPrefix) {
187 const info = await this.mediaStore.getInfo(segmentHash)
188 if (!info) return null
189
190 // Level playlist — rewrite .ts filenames
191 if (info.mimeType === 'application/vnd.apple.mpegurl') {
192 const buf = await this.mediaStore.get(segmentHash)
193 let content = buf.toString()
194
195 const videoIndex = await this.videoProcessor.getStatus(videoHash)
196 if (videoIndex && videoIndex.levels) {
197 const level = videoIndex.levels.find(l => l.m3u8 === segmentHash)
198 if (level && level.segments) {
199 const basePath = (pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + videoHash + '/stream/'
200 const fileToHash = new Map()
201 for (const seg of level.segments) {
202 fileToHash.set(seg.file, seg.hash)
203 }
204 content = content.replace(/^(.+\.ts)$/gm, function (_, filename) {
205 const h = fileToHash.get(filename)
206 return h ? basePath + h : filename
207 })
208 }
209 }
210
211 return { content, mimeType: 'application/vnd.apple.mpegurl', isPlaylist: true }
212 }
213
214 // Regular segment — return info for streaming
215 return { info, mimeType: info.mimeType || 'video/mp2t', isPlaylist: false }
216 }
217}
lib/services/note-service.js +128 -0
……@@ -0,0 +1,128 @@
1/**
2 * NoteService — Business logic for note operations.
3 *
4 * Wraps NoteManager with side effects (site publishing)
5 * and owner resolution for feeds.
6 */
7
8export class NoteService {
9 constructor ({ noteManager, authManager, sitePublisher }) {
10 this.noteManager = noteManager
11 this.authManager = authManager
12 this.sitePublisher = sitePublisher
13 }
14
15 /**
16 * List notes in a notebook with pagination.
17 */
18 async list (notebook, options = {}) {
19 const limit = Math.min(Math.max(1, parseInt(options.limit) || 50), 500)
20 const offset = Math.max(0, parseInt(options.offset) || 0)
21 return this.noteManager.listNotes(notebook, { limit, offset })
22 }
23
24 /**
25 * Get a single note, resolving by slug or hex ID.
26 */
27 async get (notebook, identifier) {
28 return this.noteManager.resolveNote(notebook, identifier)
29 }
30
31 /**
32 * Create a note and trigger site publish.
33 */
34 async create (notebook, options) {
35 const note = await this.noteManager.createNote(notebook, {
36 title: options.title,
37 content: options.content,
38 tags: options.tags,
39 type: options.type
40 })
41 this._publishInBackground(notebook)
42 return note
43 }
44
45 /**
46 * Update a note and trigger site publish.
47 */
48 async update (notebook, noteId, updates) {
49 const updated = await this.noteManager.updateNote(notebook, noteId, {
50 title: updates.title,
51 content: updates.content,
52 tags: updates.tags
53 })
54 this._publishInBackground(notebook)
55 return updated
56 }
57
58 /**
59 * Delete a note and trigger site publish.
60 */
61 async delete (notebook, noteId) {
62 const result = await this.noteManager.deleteNote(notebook, noteId)
63 this._publishInBackground(notebook)
64 return result
65 }
66
67 /**
68 * Move a note between notebooks.
69 */
70 async move (fromNotebook, noteId, toNotebook) {
71 return this.noteManager.moveNote(fromNotebook, noteId, toNotebook)
72 }
73
74 /**
75 * Merge two notes.
76 */
77 async merge (notebook, noteId1, noteId2, options) {
78 return this.noteManager.mergeNotes(notebook, noteId1, noteId2, options)
79 }
80
81 /**
82 * Split a note at headings.
83 */
84 async split (notebook, noteId, options) {
85 return this.noteManager.splitNote(notebook, noteId, options)
86 }
87
88 /**
89 * Recent notes feed with owner resolution.
90 *
91 * @param {Object} options - { limit }
92 * @returns {Promise<Array>} Notes with ownerName field
93 */
94 async recentFeed (options = {}) {
95 const limit = Math.min(parseInt(options.limit) || 20, 50)
96 const notes = await this.noteManager.recentNotes({ limit })
97
98 const registry = this.authManager.identityRegistry
99 for (const note of notes) {
100 if (note.owner) {
101 try {
102 const user = await registry.getUser(note.owner)
103 note.ownerName = user ? (user.username || user.name || 'Anonymous') : 'Anonymous'
104 } catch { note.ownerName = 'Anonymous' }
105 } else {
106 note.ownerName = 'Anonymous'
107 }
108 }
109
110 return notes
111 }
112
113 /**
114 * Search notes.
115 */
116 async search (query, options = {}) {
117 return this.noteManager.searchNotes(query, { notebook: options.notebook })
118 }
119
120 /**
121 * Trigger site publish in background (fire-and-forget).
122 */
123 _publishInBackground (notebook) {
124 if (this.sitePublisher) {
125 this.sitePublisher.publishNotebook(notebook).catch(() => {})
126 }
127 }
128}
lib/services/notebook-service.js +126 -0
……@@ -0,0 +1,126 @@
1/**
2 * NotebookService — Business logic for notebook operations.
3 *
4 * Extracts filtering, visibility, and side-effect logic from route handlers.
5 * Routes call this service; this service calls NoteManager.
6 */
7
8export class NotebookService {
9 constructor ({ noteManager, notebookCrypto, authManager, sitePublisher }) {
10 this.noteManager = noteManager
11 this.notebookCrypto = notebookCrypto
12 this.authManager = authManager
13 this.sitePublisher = sitePublisher
14 }
15
16 /**
17 * List notebooks with visibility/ownership filtering.
18 *
19 * @param {Object|null} auth - Auth result from authenticate() (null = anonymous)
20 * @param {Object} filters
21 * @param {boolean} [filters.mine] - Only current user's notebooks
22 * @param {string} [filters.owner] - Filter by owner username
23 * @returns {Promise<Array>} Filtered notebook list
24 */
25 async listFiltered (auth, filters = {}) {
26 let notebooks = await this.noteManager.listNotebooks()
27
28 const isAdmin = auth && auth.permissions && auth.permissions.admin
29
30 // Non-admins cannot see private or encrypted notebooks
31 if (!isAdmin) {
32 notebooks = notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted)
33 }
34
35 // Filter to current user's notebooks
36 if (filters.mine) {
37 if (auth && auth.userId) {
38 notebooks = notebooks.filter(nb => {
39 return nb.ownerId === auth.userId || (!nb.ownerId && isAdmin)
40 })
41 } else {
42 return []
43 }
44 }
45
46 // Filter by owner username
47 if (filters.owner) {
48 const ownerUser = await this.authManager.identityRegistry.getUserByUsername(filters.owner)
49 if (!ownerUser) return []
50
51 const userPerms = ownerUser.globalPermissions || []
52 const ownerIsAdmin = userPerms.includes('admin')
53 notebooks = notebooks.filter(nb => {
54 if (nb.ownerId === ownerUser.userId) return true
55 if (ownerIsAdmin && !nb.ownerId) return true
56 return false
57 })
58 }
59
60 return notebooks
61 }
62
63 /**
64 * Create a notebook with optional encryption and site publishing.
65 *
66 * @param {string} slug
67 * @param {Object} options - { name, description, visibility, encrypted, password }
68 * @param {string} ownerId
69 * @returns {Promise<Object>} Created notebook
70 */
71 async create (slug, options, ownerId) {
72 const encrypted = !!(options.encrypted && options.password)
73
74 if (encrypted && !this.notebookCrypto) {
75 throw new Error('Encryption not configured on server')
76 }
77
78 const notebook = await this.noteManager.createNotebook(slug, {
79 name: options.name,
80 description: options.description,
81 encrypted,
82 visibility: options.visibility || 'public',
83 ownerId
84 })
85
86 if (encrypted) {
87 const key = await this.notebookCrypto.enableEncryption(slug, options.password)
88 this.noteManager.cacheKey(slug, key)
89 }
90
91 return notebook
92 }
93
94 /**
95 * Get a single notebook by slug.
96 */
97 async get (slug) {
98 return this.noteManager.getNotebook(slug)
99 }
100
101 /**
102 * Update notebook metadata.
103 */
104 async update (slug, updates) {
105 const allowed = {}
106 if (updates.name !== undefined) allowed.name = updates.name
107 if (updates.description !== undefined) allowed.description = updates.description
108 if (updates.encrypted !== undefined) allowed.encrypted = updates.encrypted
109 if (updates.visibility !== undefined) allowed.visibility = updates.visibility
110 return this.noteManager.updateNotebook(slug, allowed)
111 }
112
113 /**
114 * Delete a notebook.
115 */
116 async delete (slug) {
117 return this.noteManager.deleteNotebook(slug)
118 }
119
120 /**
121 * Rename a notebook.
122 */
123 async rename (oldSlug, newSlug) {
124 return this.noteManager.renameNotebook(oldSlug, newSlug)
125 }
126}
lib/services/public-page-service.js +80 -0
……@@ -0,0 +1,80 @@
1/**
2 * PublicPageService — Data preparation for public-facing SSR pages.
3 *
4 * Consolidates the user/notebook/note lookups and permission checks
5 * that were scattered across web.js public route handlers.
6 */
7
8export class PublicPageService {
9 constructor ({ noteManager, authManager }) {
10 this.noteManager = noteManager
11 this.authManager = authManager
12 }
13
14 /**
15 * Get a user's public profile with their public notebooks.
16 *
17 * @param {string} username
18 * @returns {Promise<Object|null>} { user, notebooks } or null if user not found
19 */
20 async getUserProfile (username) {
21 const user = await this.authManager.identityRegistry.getUserByUsername(username)
22 if (!user) return null
23
24 const allNotebooks = await this.noteManager.listNotebooks()
25 const notebooks = allNotebooks.filter(nb =>
26 nb.ownerId === user.userId && nb.visibility !== 'private' && !nb.encrypted
27 )
28
29 return { user, notebooks }
30 }
31
32 /**
33 * Get a notebook's public view with note list.
34 *
35 * @param {string} username
36 * @param {string} bookSlug
37 * @returns {Promise<Object>} { user, notebook, notes } or throws
38 */
39 async getNotebookView (username, bookSlug) {
40 const user = await this.authManager.identityRegistry.getUserByUsername(username)
41 if (!user) return { error: 'user-not-found' }
42
43 const notebook = await this.noteManager.getNotebook(bookSlug)
44 if (!notebook || notebook.ownerId !== user.userId) return { error: 'notebook-not-found' }
45
46 if (notebook.visibility === 'private' || notebook.encrypted) {
47 return { error: 'private' }
48 }
49
50 const notes = await this.noteManager.listNotes(bookSlug)
51 return { user, notebook, notes }
52 }
53
54 /**
55 * Get a single note's public view.
56 *
57 * @param {string} username
58 * @param {string} bookSlug
59 * @param {string} noteId
60 * @returns {Promise<Object>} { user, notebook, note } or { error }
61 */
62 async getNoteView (username, bookSlug, noteId) {
63 const user = await this.authManager.identityRegistry.getUserByUsername(username)
64 if (!user) return { error: 'user-not-found' }
65
66 const notebook = await this.noteManager.getNotebook(bookSlug)
67 if (!notebook || notebook.ownerId !== user.userId) return { error: 'notebook-not-found' }
68
69 if (notebook.visibility === 'private' || notebook.encrypted) {
70 return { error: 'private' }
71 }
72
73 try {
74 const note = await this.noteManager.getNote(bookSlug, noteId)
75 return { user, notebook, note }
76 } catch {
77 return { error: 'note-not-found' }
78 }
79 }
80}
lib/views/admin-ui.js +336 -0
……@@ -0,0 +1,336 @@
1/**
2 * Admin Dashboard — P2P status, user management, notebook management
3 *
4 * SSR view with inline scripts for interactive elements.
5 */
6
7import { renderPage } from '../page-layout.js'
8
9function esc (s) {
10 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13/**
14 * Render the admin dashboard page
15 *
16 * @param {Object} data
17 * @param {Object} data.stats - { users, notebooks, notes, publicNotebooks, privateNotebooks }
18 * @param {Array} data.users - User list
19 * @param {Array} data.notebooks - Notebook list
20 * @param {Object} data.syncStatus - P2P sync status
21 * @param {Object} data.siteInfo - { siteKey, hyperUrl }
22 * @param {Object} data.mwStatus - Multiwriter status
23 * @param {Object} opts - { pathPrefix }
24 * @returns {string} Full HTML page
25 */
26export function renderAdminPage (data, opts = {}) {
27 const pathPrefix = opts.pathPrefix || ''
28 const { stats, users, notebooks, syncStatus, siteInfo, mwStatus } = data
29
30 let content = ''
31
32 // P2P Network
33 content += '<h2 class="admin-section-title">P2P Network</h2>'
34 const swarmActive = syncStatus && syncStatus.swarm && syncStatus.swarm.connected
35 const peerCount = (syncStatus && syncStatus.swarm && syncStatus.swarm.peerCount) || 0
36 content += `<div class="p2p-status-bar">
37 <span class="p2p-indicator ${swarmActive ? 'p2p-online' : 'p2p-offline'}"></span>
38 <span>${swarmActive ? 'Swarm active' : 'Swarm offline'}</span>
39 <span class="meta" style="margin-left:12px">${peerCount} peer${peerCount !== 1 ? 's' : ''} connected</span>
40 </div>`
41
42 // Site drive URL
43 if (siteInfo && siteInfo.hyperUrl) {
44 content += `<div class="p2p-site-row">
45 <label>Site Drive:</label>
46 <code class="copyable" data-copy="${esc(siteInfo.hyperUrl)}">${esc(siteInfo.hyperUrl)}</code>
47 <button class="btn btn-sm" id="btn-republish">Republish</button>
48 </div>`
49 }
50
51 // Per-notebook sharing table
52 content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
53 content += '<th>Notebook</th><th>Drive Key</th><th>Sharing</th><th>Actions</th>'
54 content += '</tr></thead><tbody>'
55 for (const nb of notebooks) {
56 const driveKey = nb.driveKey || ''
57 const shortKey = driveKey ? driveKey.substring(0, 12) + '...' : 'none'
58 const announced = syncStatus && syncStatus.announced && syncStatus.announced.includes(nb.slug)
59 content += `<tr>
60 <td>${esc(nb.name || nb.slug)}</td>
61 <td><code class="copyable p2p-key" data-copy="${esc(driveKey)}">${esc(shortKey)}</code></td>
62 <td><span class="badge ${announced ? 'badge-success' : 'badge-secondary'}">${announced ? 'sharing' : 'local only'}</span></td>
63 <td>${announced
64 ? `<button class="btn btn-sm p2p-action" data-action="unannounce" data-slug="${esc(nb.slug)}">Stop Sharing</button>`
65 : `<button class="btn btn-sm p2p-action" data-action="announce" data-slug="${esc(nb.slug)}">Share P2P</button>`
66 }</td>
67 </tr>`
68 }
69 content += '</tbody></table></div>'
70
71 // Add remote notebook
72 content += `<div class="p2p-add-remote">
73 <h4>Add Remote Notebook</h4>
74 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
75 <input type="text" id="remote-key" placeholder="Drive key (64-char hex)" style="flex:1;min-width:200px">
76 <input type="text" id="remote-slug" placeholder="Local name (optional)" style="width:150px">
77 <button class="btn btn-sm btn-primary" id="btn-add-remote">Add</button>
78 </div>
79 </div>`
80
81 // Multi-writer sync
82 content += '<h2 class="admin-section-title">Multi-Writer Sync</h2>'
83 const linked = mwStatus && mwStatus.linked
84 const sharedCount = (mwStatus && mwStatus.sharedNotebooks) || 0
85 content += `<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
86 <span class="badge ${linked ? 'badge-success' : 'badge-secondary'}">${linked ? 'Linked' : 'Not linked'}</span>
87 <span class="meta">${sharedCount} shared notebook${sharedCount !== 1 ? 's' : ''}</span>
88 </div>
89 <div style="display:flex;gap:8px">
90 <button class="btn btn-sm btn-primary" id="btn-mw-link">Create Link Invite</button>
91 <button class="btn btn-sm" id="btn-mw-join">Join with Invite</button>
92 </div>
93 <div id="mw-invite" style="display:none;margin-top:12px">
94 <input type="text" id="mw-invite-code" readonly style="font-family:monospace;font-size:0.85em">
95 <button class="btn btn-sm" id="btn-mw-copy" style="margin-top:4px">Copy</button>
96 </div>`
97
98 // Stats cards
99 content += '<h2 class="admin-section-title">Statistics</h2>'
100 content += '<div class="stats-grid">'
101 const statItems = [
102 { label: 'Users', value: stats.users || 0 },
103 { label: 'Notebooks', value: stats.notebooks || 0 },
104 { label: 'Notes', value: stats.notes || 0 },
105 { label: 'Public', value: stats.publicNotebooks || 0 },
106 { label: 'Private', value: stats.privateNotebooks || 0 }
107 ]
108 for (const s of statItems) {
109 content += `<div class="stat-card"><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div></div>`
110 }
111 content += '</div>'
112
113 // Users table
114 content += '<h2 class="admin-section-title">Users</h2>'
115 content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
116 content += '<th>Name</th><th>Username</th><th>Role</th><th>Joined</th><th>Actions</th>'
117 content += '</tr></thead><tbody>'
118 for (const u of users) {
119 const userRole = (u.globalPermissions || []).includes('admin') ? 'admin'
120 : (u.globalPermissions || []).includes('write') ? 'writer' : 'reader'
121 const joined = u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-'
122 content += `<tr data-userid="${esc(u.userId)}">
123 <td>${esc(u.name || 'Anonymous')}</td>
124 <td>${u.username ? '@' + esc(u.username) : '<span class="meta">none</span>'}</td>
125 <td><span class="badge badge-${userRole === 'admin' ? 'success' : 'secondary'}">${esc(userRole)}</span></td>
126 <td class="meta">${joined}</td>
127 <td>${userRole !== 'admin' ? `
128 <select class="role-select" data-userid="${esc(u.userId)}">
129 <option value="" disabled selected>Change role...</option>
130 <option value="read" ${userRole === 'reader' ? 'disabled' : ''}>Reader</option>
131 <option value="write" ${userRole === 'writer' ? 'disabled' : ''}>Writer</option>
132 <option value="admin">Admin</option>
133 </select>
134 <button class="btn btn-sm btn-danger delete-user" data-userid="${esc(u.userId)}" data-name="${esc(u.name || 'user')}">Delete</button>
135 ` : '<span class="meta">&mdash;</span>'}</td>
136 </tr>`
137 }
138 content += '</tbody></table></div>'
139
140 // Notebooks table
141 content += '<h2 class="admin-section-title">Notebooks</h2>'
142 content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
143 content += '<th>Name</th><th>Slug</th><th>Owner</th><th>Visibility</th><th>Notes</th><th>Transfer</th>'
144 content += '</tr></thead><tbody>'
145 for (const nb of notebooks) {
146 const ownerUser = nb.ownerId ? users.find(u => u.userId === nb.ownerId) : null
147 const ownerName = ownerUser ? (ownerUser.username || ownerUser.name || 'Unknown') : 'unowned'
148 content += `<tr>
149 <td><a href="${pathPrefix}/notebooks/${encodeURIComponent(nb.slug)}">${esc(nb.name || nb.slug)}</a></td>
150 <td class="meta">${esc(nb.slug)}</td>
151 <td>${ownerUser ? esc(ownerName) : '<span class="meta">unowned</span>'}</td>
152 <td><span class="badge badge-secondary">${esc(nb.visibility || 'public')}</span></td>
153 <td>${nb.noteCount || 0}</td>
154 <td>
155 <select class="owner-select" data-slug="${esc(nb.slug)}">
156 <option value="" disabled selected>Transfer to...</option>
157 ${users.map(u => `<option value="${esc(u.userId)}" ${u.userId === nb.ownerId ? 'disabled' : ''}>${esc(u.username || u.name || u.userId.substring(0, 8))}</option>`).join('')}
158 </select>
159 </td>
160 </tr>`
161 }
162 content += '</tbody></table></div>'
163
164 const css = `
165 .admin-section-title { margin-top:32px; margin-bottom:12px; font-size:1.1rem; }
166 .p2p-status-bar { display:flex; align-items:center; gap:8px; margin-bottom:16px; }
167 .p2p-indicator { width:10px; height:10px; border-radius:50%; }
168 .p2p-online { background:var(--success); }
169 .p2p-offline { background:var(--danger); }
170 .p2p-site-row { display:flex; align-items:center; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
171 .p2p-site-row label { font-size:0.85rem; color:var(--text-muted); }
172 .copyable { cursor:pointer; font-size:0.8rem; padding:2px 6px; background:var(--bg-secondary); border-radius:4px; }
173 .copyable:hover { background:var(--bg-hover); }
174 .p2p-add-remote { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
175 .p2p-add-remote h4 { font-size:0.9rem; margin-bottom:8px; }
176 .stats-grid { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:16px; }
177 .stat-card { background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; padding:16px 24px; text-align:center; min-width:100px; }
178 .stat-value { font-size:1.5rem; font-weight:bold; }
179 .stat-label { font-size:0.8rem; color:var(--text-muted); margin-top:4px; }
180 .admin-table-wrap { overflow-x:auto; margin-bottom:16px; }
181 .role-select, .owner-select { font-size:0.8rem; padding:4px 8px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; }
182 `
183
184 const scripts = `
185 (function () {
186 var basePath = ${JSON.stringify(pathPrefix)};
187
188 // Copy on click
189 document.querySelectorAll('.copyable').forEach(function (el) {
190 el.addEventListener('click', function () {
191 var text = el.getAttribute('data-copy') || el.textContent;
192 navigator.clipboard.writeText(text);
193 var orig = el.textContent;
194 el.textContent = 'Copied!';
195 setTimeout(function () { el.textContent = orig; }, 2000);
196 });
197 });
198
199 // P2P announce/unannounce
200 document.querySelectorAll('.p2p-action').forEach(function (btn) {
201 btn.addEventListener('click', async function () {
202 var action = btn.getAttribute('data-action');
203 var slug = btn.getAttribute('data-slug');
204 btn.disabled = true;
205 try {
206 await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(slug) + '/' + action, { method: 'POST' });
207 window.location.reload();
208 } catch (e) { alert('Failed: ' + e.message); btn.disabled = false; }
209 });
210 });
211
212 // Republish site
213 var btnRepub = document.getElementById('btn-republish');
214 if (btnRepub) btnRepub.addEventListener('click', async function () {
215 btnRepub.disabled = true;
216 try {
217 await window.fetch(basePath + '/api/site/publish', { method: 'POST' });
218 btnRepub.textContent = 'Published!';
219 setTimeout(function () { btnRepub.textContent = 'Republish'; btnRepub.disabled = false; }, 2000);
220 } catch (e) { alert('Failed: ' + e.message); btnRepub.disabled = false; }
221 });
222
223 // Add remote notebook
224 var btnAddRemote = document.getElementById('btn-add-remote');
225 if (btnAddRemote) btnAddRemote.addEventListener('click', async function () {
226 var key = document.getElementById('remote-key').value.trim();
227 var slug = document.getElementById('remote-slug').value.trim();
228 if (!key || key.length !== 64) { alert('Enter a valid 64-char hex drive key'); return; }
229 btnAddRemote.disabled = true;
230 try {
231 await window.fetch(basePath + '/api/sync/add', {
232 method: 'POST',
233 headers: { 'Content-Type': 'application/json' },
234 body: JSON.stringify({ driveKey: key, slug: slug || undefined })
235 });
236 window.location.reload();
237 } catch (e) { alert('Failed: ' + e.message); btnAddRemote.disabled = false; }
238 });
239
240 // Role change
241 document.querySelectorAll('.role-select').forEach(function (sel) {
242 sel.addEventListener('change', async function () {
243 var userId = sel.getAttribute('data-userid');
244 var role = sel.value;
245 if (!role) return;
246 try {
247 if (role === 'read') {
248 await window.fetch(basePath + '/api/admin/demote', {
249 method: 'POST', headers: { 'Content-Type': 'application/json' },
250 body: JSON.stringify({ userId: userId, role: 'read' })
251 });
252 } else {
253 await window.fetch(basePath + '/api/admin/promote', {
254 method: 'POST', headers: { 'Content-Type': 'application/json' },
255 body: JSON.stringify({ userId: userId, role: role })
256 });
257 }
258 window.location.reload();
259 } catch (e) { alert('Failed: ' + e.message); sel.value = ''; }
260 });
261 });
262
263 // Delete user
264 document.querySelectorAll('.delete-user').forEach(function (btn) {
265 btn.addEventListener('click', async function () {
266 var userId = btn.getAttribute('data-userid');
267 var name = btn.getAttribute('data-name');
268 if (!confirm('Delete user ' + name + '? This cannot be undone.')) return;
269 try {
270 await window.fetch(basePath + '/api/admin/delete-user', {
271 method: 'POST', headers: { 'Content-Type': 'application/json' },
272 body: JSON.stringify({ userId: userId })
273 });
274 window.location.reload();
275 } catch (e) { alert('Failed: ' + e.message); }
276 });
277 });
278
279 // Transfer notebook
280 document.querySelectorAll('.owner-select').forEach(function (sel) {
281 sel.addEventListener('change', async function () {
282 var slug = sel.getAttribute('data-slug');
283 var newOwner = sel.value;
284 if (!newOwner) return;
285 try {
286 await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(slug), {
287 method: 'PATCH', headers: { 'Content-Type': 'application/json' },
288 body: JSON.stringify({ ownerId: newOwner })
289 });
290 window.location.reload();
291 } catch (e) { alert('Failed: ' + e.message); sel.value = ''; }
292 });
293 });
294
295 // Multi-writer link
296 var btnLink = document.getElementById('btn-mw-link');
297 if (btnLink) btnLink.addEventListener('click', async function () {
298 btnLink.disabled = true;
299 try {
300 var r = await window.fetch(basePath + '/api/multiwriter/link', { method: 'POST' });
301 var data = await r.json();
302 var inviteEl = document.getElementById('mw-invite');
303 var codeEl = document.getElementById('mw-invite-code');
304 if (inviteEl && codeEl && data.invite) {
305 codeEl.value = data.invite;
306 inviteEl.style.display = 'block';
307 }
308 } catch (e) { alert('Failed: ' + e.message); }
309 btnLink.disabled = false;
310 });
311
312 // Multi-writer join
313 var btnJoin = document.getElementById('btn-mw-join');
314 if (btnJoin) btnJoin.addEventListener('click', async function () {
315 var invite = prompt('Paste the invite code:');
316 if (!invite) return;
317 try {
318 await window.fetch(basePath + '/api/multiwriter/join', {
319 method: 'POST', headers: { 'Content-Type': 'application/json' },
320 body: JSON.stringify({ invite: invite.trim() })
321 });
322 window.location.reload();
323 } catch (e) { alert('Failed: ' + e.message); }
324 });
325
326 // Copy invite
327 var btnCopy = document.getElementById('btn-mw-copy');
328 if (btnCopy) btnCopy.addEventListener('click', function () {
329 var code = document.getElementById('mw-invite-code');
330 if (code) navigator.clipboard.writeText(code.value);
331 });
332 })();
333 `
334
335 return renderPage('Admin', content, { pathPrefix, activePage: 'admin', css, scripts, maxWidth: 1100 })
336}
lib/views/editor-ui.js +381 -0
……@@ -0,0 +1,381 @@
1/**
2 * Editor Page — SSR shell for note editing
3 *
4 * Loads the appropriate editor (TOUI markdown, jSpreadsheet, FullCalendar)
5 * via client-side JavaScript. The server renders the page shell with
6 * toolbar, title/tags inputs, and the editor container.
7 */
8
9import { renderPage } from '../page-layout.js'
10
11function esc (s) {
12 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
13}
14
15/**
16 * Render the editor page
17 *
18 * @param {Object} notebook - Notebook metadata
19 * @param {Object|null} note - Existing note (null for new note)
20 * @param {Object} opts - { pathPrefix, noteType }
21 * @returns {string} Full HTML page
22 */
23export function renderEditorPage (notebook, note, opts = {}) {
24 const pathPrefix = opts.pathPrefix || ''
25 const nbSlug = notebook.slug
26 const isNew = !note
27 const noteType = (note && note.type) || opts.noteType || 'markdown'
28 const title = isNew ? '' : (note.title || '')
29 const tags = isNew ? '' : (note.tags || []).join(', ')
30 const noteId = note ? note.id : null
31
32 // Serialize note content for the editor to pick up
33 const contentJson = JSON.stringify(note ? (note.content || '') : '')
34
35 let content = ''
36
37 // Editor toolbar
38 content += `<div class="editor-toolbar">
39 <a class="back-link" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}">&larr; ${esc(notebook.name || nbSlug)}</a>
40 <input type="text" id="editor-title" placeholder="Note title" value="${esc(title)}">
41 <input type="text" id="editor-tags" placeholder="Tags (comma-separated)" value="${esc(tags)}">
42 <div class="editor-actions">
43 <button class="btn btn-primary" id="btn-save">Save</button>
44 <a class="btn" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}${noteId ? '/notes/' + encodeURIComponent(note.slug || noteId) : ''}">Cancel</a>
45 </div>
46 </div>`
47
48 // Editor container — JS fills this
49 content += '<div id="editor-container"></div>'
50
51 // Upload progress overlay (hidden)
52 content += `<div id="upload-overlay" style="display:none">
53 <div class="upload-box">
54 <div id="upload-label">Uploading...</div>
55 <div class="upload-track"><div id="upload-bar" class="upload-bar"></div></div>
56 <div id="upload-pct">0%</div>
57 </div>
58 </div>`
59
60 const css = `
61 .editor-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:16px; }
62 .editor-toolbar input[type="text"] { flex:1; min-width:200px; }
63 .editor-actions { display:flex; gap:8px; margin-left:auto; }
64 #editor-container { min-height:60vh; }
65 #upload-overlay {
66 position:fixed; top:0; left:0; right:0; bottom:0;
67 background:rgba(0,0,0,0.5); z-index:10000;
68 display:flex; align-items:center; justify-content:center;
69 }
70 .upload-box { background:var(--bg-secondary); padding:24px; border-radius:8px; min-width:300px; text-align:center; }
71 .upload-track { height:8px; background:var(--bg-card); border-radius:4px; margin:12px 0; overflow:hidden; }
72 .upload-bar { height:100%; background:var(--accent); border-radius:4px; width:0%; transition:width 0.2s; }
73 /* TOUI editor theme overrides */
74 .toastui-editor-defaultUI { background:var(--bg) !important; }
75 .toastui-editor-defaultUI .toastui-editor-md-container { background:var(--bg) !important; }
76 .ProseMirror { color:var(--text) !important; min-height:50vh; }
77 `
78
79 // Vendor scripts needed for the editor
80 const vendorHead = `
81 <link rel="stylesheet" href="${pathPrefix}/web/vendor/toastui-editor.min.css">
82 <link rel="stylesheet" href="${pathPrefix}/web/vendor/jsuites.min.css">
83 <link rel="stylesheet" href="${pathPrefix}/web/vendor/jspreadsheet.min.css">
84 <script src="${pathPrefix}/web/vendor/toastui-editor-all.min.js"></script>
85 <script src="${pathPrefix}/web/vendor/jsuites.min.js"></script>
86 <script src="${pathPrefix}/web/vendor/jspreadsheet.min.js"></script>
87 <script src="${pathPrefix}/web/vendor/fullcalendar.min.js"></script>
88 `
89
90 const scripts = `
91 (function () {
92 var basePath = ${JSON.stringify(pathPrefix)};
93 var nbSlug = ${JSON.stringify(nbSlug)};
94 var noteId = ${JSON.stringify(noteId)};
95 var noteType = ${JSON.stringify(noteType)};
96 var initialContent = ${contentJson};
97 var container = document.getElementById('editor-container');
98
99 var tuiEditor = null;
100 var spreadsheetInstance = null;
101 var calendarInstance = null;
102
103 // ========== UPLOAD HELPERS ==========
104
105 function showUploadProgress (label) {
106 document.getElementById('upload-label').textContent = label || 'Uploading...';
107 document.getElementById('upload-bar').style.width = '0%';
108 document.getElementById('upload-pct').textContent = '0%';
109 document.getElementById('upload-overlay').style.display = 'flex';
110 }
111
112 function updateUploadProgress (pct) {
113 document.getElementById('upload-bar').style.width = pct + '%';
114 document.getElementById('upload-pct').textContent = Math.round(pct) + '%';
115 }
116
117 function hideUploadProgress () {
118 document.getElementById('upload-overlay').style.display = 'none';
119 }
120
121 async function uploadMedia (file, onProgress) {
122 var headers = {};
123 if (window.HnIdentity && window.HnIdentity.getPublicKey()) {
124 try {
125 var ch = await window.HnIdentity.getFreshChallenge();
126 var sig = await window.HnIdentity.signChallenge(ch.challenge);
127 headers['X-Wirecore-PublicKey'] = window.HnIdentity.getPublicKey();
128 headers['X-Wirecore-Signature'] = sig;
129 headers['X-Wirecore-Challenge'] = ch.challengeId;
130 } catch (e) {}
131 }
132 return new Promise(function (resolve, reject) {
133 var xhr = new XMLHttpRequest();
134 xhr.open('POST', basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/media');
135 xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
136 for (var h in headers) xhr.setRequestHeader(h, headers[h]);
137 xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total * 100); };
138 xhr.onload = function () {
139 if (xhr.status >= 200 && xhr.status < 300) {
140 try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error('Invalid response')); }
141 } else { reject(new Error('Upload failed: ' + xhr.status)); }
142 };
143 xhr.onerror = function () { reject(new Error('Network error')); };
144 xhr.send(file);
145 });
146 }
147
148 function insertRawMarkdown (text) {
149 if (!tuiEditor) return;
150 tuiEditor.insertText(text);
151 }
152
153 // ========== MARKDOWN EDITOR ==========
154
155 if (noteType === 'markdown' || noteType === 'md' || !noteType) {
156 // Video upload button
157 var videoBtnEl = document.createElement('button');
158 videoBtnEl.className = 'toastui-editor-toolbar-icons';
159 videoBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
160 videoBtnEl.textContent = '🎬';
161 videoBtnEl.title = 'Insert video';
162 var videoInput = document.createElement('input');
163 videoInput.type = 'file'; videoInput.accept = 'video/*'; videoInput.style.display = 'none';
164 document.body.appendChild(videoInput);
165 videoBtnEl.addEventListener('click', function () { videoInput.click(); });
166 videoInput.addEventListener('change', async function () {
167 if (!videoInput.files[0]) return;
168 showUploadProgress('Uploading video...');
169 try {
170 var data = await uploadMedia(videoInput.files[0], updateUploadProgress);
171 hideUploadProgress();
172 insertRawMarkdown('![video](' + basePath + data.url + ')');
173 } catch (e) { hideUploadProgress(); alert('Upload failed: ' + e.message); }
174 videoInput.value = '';
175 });
176
177 // Gallery upload button
178 var galleryBtnEl = document.createElement('button');
179 galleryBtnEl.className = 'toastui-editor-toolbar-icons';
180 galleryBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
181 galleryBtnEl.textContent = '🖼';
182 galleryBtnEl.title = 'Insert gallery';
183 var galleryInput = document.createElement('input');
184 galleryInput.type = 'file'; galleryInput.accept = 'image/*'; galleryInput.multiple = true; galleryInput.style.display = 'none';
185 document.body.appendChild(galleryInput);
186 galleryBtnEl.addEventListener('click', function () { galleryInput.click(); });
187 galleryInput.addEventListener('change', async function () {
188 var files = Array.from(galleryInput.files);
189 if (files.length === 0) return;
190 showUploadProgress('Uploading gallery (' + files.length + ' images)...');
191 var lines = [];
192 for (var i = 0; i < files.length; i++) {
193 updateUploadProgress((i / files.length) * 100);
194 try {
195 var data = await uploadMedia(files[i], function () {});
196 lines.push('![' + (files[i].name || 'image') + '](' + basePath + data.url + ')');
197 } catch (e) { console.error('Gallery upload failed:', e); }
198 }
199 hideUploadProgress();
200 if (lines.length) insertRawMarkdown('\\n\`\`\`gallery\\n' + lines.join('\\n') + '\\n\`\`\`\\n');
201 galleryInput.value = '';
202 });
203
204 tuiEditor = new toastui.Editor({
205 el: container,
206 initialEditType: 'markdown',
207 previewStyle: 'vertical',
208 height: 'calc(100vh - 200px)',
209 initialValue: initialContent,
210 usageStatistics: false,
211 toolbarItems: [
212 ['heading', 'bold', 'italic', 'strike'],
213 ['hr', 'quote'],
214 ['ul', 'ol', 'task', 'indent', 'outdent'],
215 ['table', 'image', 'link'],
216 ['code', 'codeblock'],
217 [{ name: 'video', tooltip: 'Insert video', el: videoBtnEl },
218 { name: 'gallery', tooltip: 'Insert gallery', el: galleryBtnEl }],
219 ['scrollSync']
220 ],
221 hooks: {
222 addImageBlobHook: function (blob, callback) {
223 var ct = blob.type || 'application/octet-stream';
224 var label = ct.startsWith('video/') ? 'Uploading video...' : 'Uploading image...';
225 showUploadProgress(label);
226 uploadMedia(blob, updateUploadProgress)
227 .then(function (data) {
228 hideUploadProgress();
229 var mediaUrl = basePath + data.url;
230 if (ct.startsWith('video/')) { insertRawMarkdown('![video](' + mediaUrl + ')'); callback('', ''); }
231 else { callback(mediaUrl, blob.name || 'image'); }
232 })
233 .catch(function (err) { hideUploadProgress(); alert('Upload failed: ' + err.message); callback('', ''); });
234 return false;
235 }
236 }
237 });
238 }
239
240 // ========== SPREADSHEET EDITOR ==========
241
242 else if (noteType === 'spreadsheet') {
243 var sheetData = [['', '', '', ''], ['', '', '', ''], ['', '', '', '']];
244 var sheetColumns;
245 if (initialContent) {
246 try {
247 var parsed = JSON.parse(initialContent);
248 sheetData = parsed.data || sheetData;
249 sheetColumns = parsed.columns;
250 } catch (e) {}
251 }
252 spreadsheetInstance = jspreadsheet(container, {
253 data: sheetData,
254 columns: sheetColumns,
255 minDimensions: [4, 5],
256 tableOverflow: true,
257 tableWidth: '100%'
258 });
259 }
260
261 // ========== CALENDAR EDITOR ==========
262
263 else if (noteType === 'calendar') {
264 var calData = { events: [], settings: {} };
265 if (initialContent) { try { calData = JSON.parse(initialContent); } catch (e) {} }
266 var calSettings = calData.settings || {};
267
268 calendarInstance = new FullCalendar.Calendar(container, {
269 initialView: calSettings.defaultView || 'dayGridMonth',
270 firstDay: calSettings.firstDay != null ? calSettings.firstDay : 1,
271 headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
272 events: (calData.events || []).map(function (ev) {
273 return { id: ev.id, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, color: ev.color, extendedProps: { description: ev.description } };
274 }),
275 editable: true,
276 selectable: true,
277 height: 'auto',
278 dateClick: function (info) { showEventModal({ start: info.dateStr, allDay: info.allDay }); },
279 eventClick: function (info) {
280 var ev = info.event;
281 showEventModal({ id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr, allDay: ev.allDay, color: ev.backgroundColor, description: (ev.extendedProps && ev.extendedProps.description) || '' });
282 }
283 });
284 calendarInstance.render();
285
286 function showEventModal (evt) {
287 var isEdit = !!evt.id;
288 var modal = document.createElement('div');
289 modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center';
290 modal.innerHTML = '<div style="background:var(--bg-secondary);padding:24px;border-radius:8px;min-width:350px;max-width:500px">'
291 + '<h3>' + (isEdit ? 'Edit Event' : 'New Event') + '</h3>'
292 + '<div style="margin:12px 0"><label>Title</label><input type="text" id="evt-title" value="' + esc(evt.title || '') + '" style="width:100%"></div>'
293 + '<div style="margin:12px 0"><label>Start</label><input type="text" id="evt-start" value="' + esc(evt.start || '') + '" style="width:100%"></div>'
294 + '<div style="margin:12px 0"><label>End</label><input type="text" id="evt-end" value="' + esc(evt.end || '') + '" style="width:100%"></div>'
295 + '<div style="margin:12px 0"><label>Color</label><input type="color" id="evt-color" value="' + (evt.color || '#569cd6') + '"></div>'
296 + '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">'
297 + (isEdit ? '<button id="evt-delete" class="btn btn-danger" style="margin-right:auto">Delete</button>' : '')
298 + '<button id="evt-cancel" class="btn">Cancel</button>'
299 + '<button id="evt-save" class="btn btn-primary">Save</button></div></div>';
300 document.body.appendChild(modal);
301 modal.querySelector('#evt-cancel').addEventListener('click', function () { modal.remove(); });
302 if (isEdit) modal.querySelector('#evt-delete').addEventListener('click', function () {
303 var existing = calendarInstance.getEventById(evt.id);
304 if (existing) existing.remove();
305 modal.remove();
306 });
307 modal.querySelector('#evt-save').addEventListener('click', function () {
308 var newEvt = {
309 id: evt.id || 'evt_' + Date.now(),
310 title: modal.querySelector('#evt-title').value || 'Untitled',
311 start: modal.querySelector('#evt-start').value,
312 end: modal.querySelector('#evt-end').value || null,
313 color: modal.querySelector('#evt-color').value
314 };
315 if (isEdit) { var existing = calendarInstance.getEventById(evt.id); if (existing) existing.remove(); }
316 calendarInstance.addEvent(newEvt);
317 modal.remove();
318 });
319 }
320 }
321
322 // ========== SAVE ==========
323
324 document.getElementById('btn-save').addEventListener('click', async function () {
325 var titleInput = document.getElementById('editor-title');
326 var tagsInput = document.getElementById('editor-tags');
327 var noteTitle = titleInput.value.trim();
328 var noteTags = tagsInput.value.split(',').map(function (t) { return t.trim(); }).filter(function (t) { return t; });
329
330 if (!noteTitle) { alert('Title is required'); titleInput.focus(); return; }
331
332 var content = '';
333 var type = noteType;
334
335 if (spreadsheetInstance) {
336 type = 'spreadsheet';
337 var sheetData = spreadsheetInstance.getData ? spreadsheetInstance.getData() : [];
338 var sheetHeaders = spreadsheetInstance.getHeaders ? spreadsheetInstance.getHeaders(true) : [];
339 content = JSON.stringify({ data: sheetData, columns: sheetHeaders.length ? sheetHeaders.map(function (h) { return { title: h }; }) : undefined });
340 } else if (calendarInstance) {
341 type = 'calendar';
342 var events = calendarInstance.getEvents().map(function (ev) {
343 return { id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr || null, allDay: ev.allDay, color: ev.backgroundColor || null, description: (ev.extendedProps && ev.extendedProps.description) || '' };
344 });
345 content = JSON.stringify({ events: events, settings: { defaultView: calendarInstance.view.type, firstDay: 1 } });
346 } else if (tuiEditor) {
347 content = tuiEditor.getMarkdown();
348 }
349
350 var btn = document.getElementById('btn-save');
351 btn.disabled = true; btn.textContent = 'Saving...';
352
353 try {
354 if (noteId) {
355 await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId), {
356 method: 'PATCH',
357 headers: { 'Content-Type': 'application/json' },
358 body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags })
359 });
360 window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId);
361 } else {
362 var res = await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes', {
363 method: 'POST',
364 headers: { 'Content-Type': 'application/json' },
365 body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags, type: type })
366 });
367 var data = await res.json();
368 var note = data.note || data;
369 window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(note.slug || note.id);
370 }
371 } catch (err) {
372 alert('Error saving: ' + (err.message || 'Unknown error'));
373 btn.disabled = false; btn.textContent = 'Save';
374 }
375 });
376 })();
377 `
378
379 const pageTitle = isNew ? 'New Note' : 'Edit: ' + title
380 return renderPage(pageTitle, content, { pathPrefix, activePage: '', css, scripts, head: vendorHead, maxWidth: 1200 })
381}
lib/views/home-ui.js +108 -0
……@@ -0,0 +1,108 @@
1/**
2 * Home Page — Recent notes feed
3 *
4 * SSR view showing recent notes across all public notebooks.
5 */
6
7import { renderPage } from '../page-layout.js'
8import { renderMarkdown } from '../markdown.js'
9
10/**
11 * Escape text for HTML
12 */
13function esc (s) {
14 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
15}
16
17/**
18 * Render the home page
19 *
20 * @param {Array} recentNotes - Recent notes with ownerName field
21 * @param {Array} notebooks - Notebook list for sidebar
22 * @param {Object} opts - { pathPrefix }
23 * @returns {string} Full HTML page
24 */
25export function renderHomePage (recentNotes, notebooks, opts = {}) {
26 const pathPrefix = opts.pathPrefix || ''
27
28 let sidebar = '<div class="sidebar">'
29 sidebar += '<h3>Notebooks</h3>'
30 if (notebooks.length === 0) {
31 sidebar += '<p class="meta">No notebooks yet</p>'
32 } else {
33 for (const nb of notebooks) {
34 sidebar += `<a class="sidebar-item" href="${pathPrefix}/notebooks/${encodeURIComponent(nb.slug)}">`
35 sidebar += `<span class="sidebar-name">${esc(nb.name || nb.slug)}</span>`
36 sidebar += `<span class="sidebar-count">${nb.noteCount || 0}</span>`
37 sidebar += '</a>'
38 }
39 }
40 sidebar += '</div>'
41
42 let feed = '<div class="feed">'
43 feed += '<h1>Recent Notes</h1>'
44
45 if (recentNotes.length === 0) {
46 feed += '<p class="meta">No notes yet. Create a notebook to get started.</p>'
47 } else {
48 for (const note of recentNotes) {
49 const href = `${pathPrefix}/notebooks/${encodeURIComponent(note.notebook)}/notes/${encodeURIComponent(note.slug || note.id)}`
50 feed += `<a class="card feed-card" href="${href}">`
51 feed += `<h3>${esc(note.title || 'Untitled')}</h3>`
52 feed += `<div class="feed-meta">`
53 feed += `<span>${esc(note.notebook)}</span>`
54 feed += `<span>${esc(note.ownerName || 'Anonymous')}</span>`
55 feed += `<span>${formatDate(note.updatedAt || note.createdAt)}</span>`
56 feed += '</div>'
57 if (note.content) {
58 const snippet = note.content.substring(0, 160).replace(/\n/g, ' ')
59 feed += `<div class="desc">${esc(snippet)}${note.content.length > 160 ? '...' : ''}</div>`
60 }
61 if (note.tags && note.tags.length > 0) {
62 feed += '<div class="tags">'
63 for (const t of note.tags) {
64 feed += `<span class="tag">${esc(t)}</span>`
65 }
66 feed += '</div>'
67 }
68 feed += '</a>'
69 }
70 }
71 feed += '</div>'
72
73 const content = `<div class="home-layout">${sidebar}${feed}</div>`
74
75 const css = `
76 .home-layout { display: flex; gap: 24px; }
77 .sidebar { width: 220px; flex-shrink: 0; }
78 .sidebar h3 { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
79 .sidebar-item {
80 display: flex; justify-content: space-between; align-items: center;
81 padding: 8px 12px; margin: 2px 0; border-radius: 6px;
82 text-decoration: none; color: var(--text); font-size: 0.9rem;
83 }
84 .sidebar-item:hover { background: var(--bg-hover); text-decoration: none; }
85 .sidebar-count { font-size: 0.8rem; color: var(--text-muted); }
86 .feed { flex: 1; min-width: 0; }
87 .feed-card { margin-bottom: 12px; }
88 .feed-meta { display: flex; gap: 12px; font-size: 0.8rem; color: var(--text-muted); margin: 4px 0; }
89 @media (max-width: 768px) {
90 .home-layout { flex-direction: column; }
91 .sidebar { width: 100%; }
92 }
93 `
94
95 return renderPage('Home', content, { pathPrefix, activePage: 'home', css, maxWidth: 1100 })
96}
97
98function formatDate (d) {
99 if (!d) return ''
100 const date = new Date(d)
101 const now = new Date()
102 const diff = now - date
103 if (diff < 60000) return 'just now'
104 if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'
105 if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'
106 if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago'
107 return date.toLocaleDateString()
108}
lib/views/note-ui.js +194 -0
……@@ -0,0 +1,194 @@
1/**
2 * Note View — Single note display with rendered markdown
3 *
4 * SSR view showing note content, tags, and links panel.
5 */
6
7import { renderPage } from '../page-layout.js'
8import { renderMarkdown } from '../markdown.js'
9
10function esc (s) {
11 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
12}
13
14/**
15 * Render the note view page
16 *
17 * @param {Object} notebook - Notebook metadata
18 * @param {Object} note - Note object with content, tags, etc.
19 * @param {Object} opts - { pathPrefix, links, backlinks, canWrite }
20 * @returns {string} Full HTML page
21 */
22export function renderNotePage (notebook, note, opts = {}) {
23 const pathPrefix = opts.pathPrefix || ''
24 const canWrite = opts.canWrite || false
25 const links = opts.links || []
26 const backlinks = opts.backlinks || []
27 const nbSlug = notebook.slug
28
29 let content = ''
30
31 // Navigation and actions
32 content += `<div class="note-header">`
33 content += `<a class="back-link" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}">&larr; ${esc(notebook.name || nbSlug)}</a>`
34 if (canWrite) {
35 content += `<div class="note-actions">`
36 content += `<a class="btn" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}/notes/${encodeURIComponent(note.slug || note.id)}/edit">Edit</a>`
37 content += `</div>`
38 }
39 content += `</div>`
40
41 // Title
42 content += `<h1>${esc(note.title || 'Untitled')}</h1>`
43
44 // Meta
45 content += '<div class="meta">'
46 if (note.updatedAt) {
47 content += `Updated ${new Date(note.updatedAt).toLocaleDateString()}`
48 } else if (note.createdAt) {
49 content += `Created ${new Date(note.createdAt).toLocaleDateString()}`
50 }
51 content += '</div>'
52
53 // Tags
54 if (note.tags && note.tags.length > 0) {
55 content += '<div class="tags">'
56 for (const t of note.tags) {
57 content += `<span class="tag">${esc(t)}</span>`
58 }
59 content += '</div>'
60 }
61
62 // Content
63 if (note.type === 'spreadsheet' && note.content) {
64 content += renderSpreadsheet(note.content)
65 } else if (note.type === 'calendar' && note.content) {
66 content += renderCalendarEvents(note.content)
67 } else {
68 const renderedMd = renderMarkdown(note.content || '', {
69 notebookSlug: nbSlug,
70 pathPrefix
71 })
72 content += `<div class="markdown-body">${renderedMd}</div>`
73 }
74
75 // Links panel
76 if (links.length > 0 || backlinks.length > 0) {
77 content += '<div class="links-panel">'
78 if (links.length > 0) {
79 content += '<div class="links-section">'
80 content += '<h3>Links</h3>'
81 for (const link of links) {
82 const href = `${pathPrefix}/notebooks/${encodeURIComponent(link.notebook || nbSlug)}/notes/${encodeURIComponent(link.slug || link.id || link.noteId)}`
83 content += `<a class="link-item" href="${href}">${esc(link.title || link.target || 'Untitled')}</a>`
84 }
85 content += '</div>'
86 }
87 if (backlinks.length > 0) {
88 content += '<div class="links-section">'
89 content += '<h3>Backlinks</h3>'
90 for (const bl of backlinks) {
91 const href = `${pathPrefix}/notebooks/${encodeURIComponent(bl.notebook || nbSlug)}/notes/${encodeURIComponent(bl.slug || bl.id || bl.noteId)}`
92 content += `<a class="link-item" href="${href}">${esc(bl.title || 'Untitled')}</a>`
93 }
94 content += '</div>'
95 }
96 content += '</div>'
97 }
98
99 const css = `
100 .note-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
101 .note-actions { display: flex; gap: 8px; }
102 .links-panel { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); }
103 .links-section { margin-bottom: 16px; }
104 .links-section h3 { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; }
105 .link-item {
106 display: inline-block; padding: 4px 12px; margin: 2px 4px 2px 0;
107 background: var(--bg-secondary); border-radius: 6px; font-size: 0.85rem;
108 text-decoration: none; color: var(--accent);
109 }
110 .link-item:hover { background: var(--bg-hover); text-decoration: none; }
111 `
112
113 // Include video player script for HLS upgrade
114 const scripts = `
115 (function () {
116 var players = document.querySelectorAll('.hn-video-player');
117 if (players.length === 0) return;
118
119 var script = document.createElement('script');
120 script.src = '${pathPrefix}/web/vendor/hls.min.js';
121 script.onload = function () {
122 players.forEach(function (el) {
123 var stream = el.getAttribute('data-stream');
124 var fallback = el.getAttribute('data-fallback');
125 var video = el.querySelector('video');
126 if (!stream || !video) return;
127
128 if (typeof Hls !== 'undefined' && Hls.isSupported()) {
129 var hls = new Hls();
130 hls.loadSource(stream);
131 hls.attachMedia(video);
132 } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
133 video.src = stream;
134 }
135 });
136 };
137 document.body.appendChild(script);
138 })();
139 `
140
141 return renderPage(note.title || 'Note', content, { pathPrefix, activePage: '', css, scripts })
142}
143
144/**
145 * Render spreadsheet data as an HTML table
146 */
147function renderSpreadsheet (content) {
148 try {
149 const data = typeof content === 'string' ? JSON.parse(content) : content
150 if (!Array.isArray(data)) return '<p class="meta">Invalid spreadsheet data</p>'
151
152 let html = '<table class="spreadsheet">'
153 for (let r = 0; r < data.length; r++) {
154 html += '<tr>'
155 const row = data[r] || []
156 for (let c = 0; c < row.length; c++) {
157 const tag = r === 0 ? 'th' : 'td'
158 html += `<${tag}>${esc(String(row[c] || ''))}</${tag}>`
159 }
160 html += '</tr>'
161 }
162 html += '</table>'
163 return html
164 } catch {
165 return '<p class="meta">Could not render spreadsheet</p>'
166 }
167}
168
169/**
170 * Render calendar events as a simple list (no interactive calendar in SSR)
171 */
172function renderCalendarEvents (content) {
173 try {
174 const events = typeof content === 'string' ? JSON.parse(content) : content
175 if (!Array.isArray(events)) return '<p class="meta">Invalid calendar data</p>'
176
177 let html = '<div class="calendar-events">'
178 html += '<h3>Events</h3>'
179 if (events.length === 0) {
180 html += '<p class="meta">No events</p>'
181 } else {
182 for (const ev of events) {
183 html += '<div class="calendar-event">'
184 html += `<strong>${esc(ev.title || 'Untitled')}</strong>`
185 if (ev.start) html += ` <span class="meta">${new Date(ev.start).toLocaleDateString()}</span>`
186 html += '</div>'
187 }
188 }
189 html += '</div>'
190 return html
191 } catch {
192 return '<p class="meta">Could not render calendar</p>'
193 }
194}
lib/views/notebook-ui.js +87 -0
……@@ -0,0 +1,87 @@
1/**
2 * Notebook View — Note list for a single notebook
3 *
4 * SSR view showing notebook metadata and a list of notes.
5 */
6
7import { renderPage } from '../page-layout.js'
8
9function esc (s) {
10 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13/**
14 * Render the notebook view page
15 *
16 * @param {Object} notebook - Notebook metadata
17 * @param {Array} notes - Note list
18 * @param {Object} opts - { pathPrefix, auth }
19 * @returns {string} Full HTML page
20 */
21export function renderNotebookPage (notebook, notes, opts = {}) {
22 const pathPrefix = opts.pathPrefix || ''
23 const canWrite = opts.canWrite || false
24 const slug = notebook.slug
25
26 let content = ''
27
28 // Header
29 content += `<div class="nb-header">`
30 content += `<div class="nb-header-left">`
31 content += `<a class="back-link" href="${pathPrefix}/">&larr; Home</a>`
32 content += `<h1>${esc(notebook.name || slug)}</h1>`
33 if (notebook.description) {
34 content += `<p class="meta">${esc(notebook.description)}</p>`
35 }
36 content += `<div class="meta">${notes.length} note${notes.length !== 1 ? 's' : ''}`
37 if (notebook.visibility) {
38 content += ` &middot; <span class="badge badge-secondary">${esc(notebook.visibility)}</span>`
39 }
40 content += `</div>`
41 content += `</div>`
42 if (canWrite) {
43 content += `<div class="nb-header-right">`
44 content += `<a class="btn btn-primary" href="${pathPrefix}/notebooks/${encodeURIComponent(slug)}/notes/new">New Note</a>`
45 content += `</div>`
46 }
47 content += `</div>`
48
49 // Note list
50 if (notes.length === 0) {
51 content += '<div class="alert alert-info">No notes yet. Create your first note to get started.</div>'
52 } else {
53 content += '<div class="note-grid">'
54 for (const note of notes) {
55 const href = `${pathPrefix}/notebooks/${encodeURIComponent(slug)}/notes/${encodeURIComponent(note.slug || note.id)}`
56 content += `<a class="card note-card" href="${href}">`
57 content += `<h3>${esc(note.title || 'Untitled')}</h3>`
58 if (note.content) {
59 const snippet = note.content.substring(0, 140).replace(/\n/g, ' ')
60 content += `<div class="desc">${esc(snippet)}${note.content.length > 140 ? '...' : ''}</div>`
61 }
62 content += `<div class="note-card-meta">`
63 if (note.tags && note.tags.length > 0) {
64 for (const t of note.tags.slice(0, 3)) {
65 content += `<span class="tag">${esc(t)}</span>`
66 }
67 }
68 if (note.updatedAt) {
69 content += `<span class="note-date">${new Date(note.updatedAt).toLocaleDateString()}</span>`
70 }
71 content += `</div>`
72 content += '</a>'
73 }
74 content += '</div>'
75 }
76
77 const css = `
78 .nb-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
79 .nb-header-left { flex: 1; }
80 .nb-header-right { margin-left: 16px; padding-top: 28px; }
81 .note-grid { display: flex; flex-direction: column; gap: 8px; }
82 .note-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
83 .note-date { font-size: 0.8rem; color: var(--text-muted); margin-left: auto; }
84 `
85
86 return renderPage(notebook.name || slug, content, { pathPrefix, activePage: '', css })
87}
lib/views/profile-ui.js +263 -0
……@@ -0,0 +1,263 @@
1/**
2 * Profile Page — User settings, identity management, theme
3 *
4 * SSR view with inline scripts for interactive elements.
5 */
6
7import { renderPage } from '../page-layout.js'
8
9function esc (s) {
10 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13/**
14 * Render the profile/settings page
15 *
16 * @param {Object} profile - User profile from /api/user/profile
17 * @param {Object} whoami - Auth info from /api/whoami
18 * @param {Object} opts - { pathPrefix }
19 * @returns {string} Full HTML page
20 */
21export function renderProfilePage (profile, whoami, opts = {}) {
22 const pathPrefix = opts.pathPrefix || ''
23 const username = profile.username || ''
24 const displayName = profile.name || 'Anonymous'
25 const pubkey = profile.publicKey || ''
26 const method = profile.method || whoami.method || 'web-identity'
27 const isAdmin = whoami.permissions && whoami.permissions.admin
28 const role = isAdmin ? 'admin' : 'writer'
29 const currentTheme = profile.theme || 'dark'
30 const identities = profile.identities || []
31
32 let content = '<div class="settings-card">'
33
34 // User status card
35 content += `<div class="profile-header">
36 <div class="profile-info">
37 <div class="profile-name">${esc(displayName)}</div>
38 ${username ? `<div class="meta">@${esc(username)}</div>` : '<div class="meta">No username set</div>'}
39 </div>
40 <span class="badge badge-${role === 'admin' ? 'success' : 'secondary'}">${esc(role)}</span>
41 </div>`
42
43 // Username section
44 content += '<div class="settings-section">'
45 content += '<div class="settings-row"><label>Username</label><div class="settings-value">'
46 if (username) {
47 content += `<span>@${esc(username)}</span> <button class="btn btn-sm" id="btn-change-username">Change</button>`
48 } else {
49 content += `<span class="meta">Not set</span> <button class="btn btn-sm btn-primary" id="btn-set-username">Set username</button>`
50 }
51 content += '</div></div>'
52
53 // Display name
54 content += `<div class="settings-row"><label>Display Name</label><div class="settings-value">
55 <span id="profile-display-name">${esc(displayName)}</span>
56 <button class="btn btn-sm" id="btn-edit-name">Edit</button>
57 </div></div>`
58
59 // Theme picker
60 content += '<div class="settings-row"><label>Theme</label><div class="settings-value"><div class="theme-options">'
61 for (const theme of ['light', 'dark', 'dark-blue']) {
62 const active = currentTheme === theme ? ' theme-active' : ''
63 const label = theme === 'dark-blue' ? 'Dark Blue' : theme.charAt(0).toUpperCase() + theme.slice(1)
64 content += `<button class="theme-option${active}" data-theme="${theme}">
65 <div class="theme-swatch swatch-${theme}"></div>${label}
66 </button>`
67 }
68 content += '</div></div></div>'
69
70 // Auth method
71 content += `<div class="settings-row"><label>Auth Method</label><div class="settings-value">
72 <span>${esc(method)}</span>
73 </div></div>`
74 content += '</div>'
75
76 // Identity section
77 content += '<div class="settings-section"><h3>Identity</h3>'
78 if (identities.length > 0) {
79 for (const ident of identities) {
80 content += `<div class="settings-row"><label>${esc(ident.method || 'identity')}</label>
81 <div class="settings-value"><code class="identity-code">${esc(ident.identityId || ident.publicKey || '')}</code></div>
82 </div>`
83 }
84 }
85
86 // Keypair management buttons
87 content += `<div class="keypair-actions">
88 <button class="btn btn-sm" id="btn-copy-pubkey">Copy Public Key</button>
89 <button class="btn btn-sm" id="btn-copy-keypair">Copy Keypair</button>
90 <button class="btn btn-sm" id="btn-show-keypair">Show Keypair</button>
91 <button class="btn btn-sm" id="btn-export-keypair">Export JSON</button>
92 </div>`
93 content += '<div id="keypair-display" style="display:none"></div>'
94 content += '</div>'
95
96 // Device linking
97 content += `<div class="settings-section"><h3>Link Another Device</h3>
98 <p class="meta" style="margin-bottom:8px">Import your keypair on another device to access your account there.</p>
99 <button class="btn btn-sm" id="btn-import-keypair">Import Keypair</button>
100 <div id="import-form" style="display:none;margin-top:12px">
101 <input type="text" id="import-input" placeholder="Paste 132-char keypair string" style="margin-bottom:8px">
102 <button class="btn btn-sm btn-primary" id="btn-do-import">Import</button>
103 </div>
104 </div>`
105
106 // Danger zone
107 content += `<div class="settings-section"><h3 style="color:var(--danger)">Danger Zone</h3>
108 <button class="btn btn-sm btn-danger" id="btn-logout">Clear Identity & Logout</button>
109 </div>`
110
111 content += '</div>'
112
113 const css = `
114 .settings-card { max-width: 600px; }
115 .profile-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid var(--border); }
116 .profile-info { flex:1; }
117 .profile-name { font-size:1.3rem; font-weight:bold; }
118 .settings-section { margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid var(--border); }
119 .settings-section h3 { font-size:1rem; margin-bottom:12px; }
120 .settings-row { display:flex; align-items:center; padding:8px 0; gap:12px; }
121 .settings-row label { min-width:120px; font-size:0.85rem; color:var(--text-muted); }
122 .settings-value { flex:1; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
123 .btn-sm { padding:4px 12px; font-size:0.8rem; }
124 .identity-code { font-size:0.7rem; word-break:break-all; color:var(--text-muted); }
125 .keypair-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
126 .theme-options { display:flex; gap:8px; }
127 .theme-option { display:flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg-secondary); color:var(--text); cursor:pointer; font-size:0.85rem; }
128 .theme-option:hover { border-color:var(--accent); }
129 .theme-active { border-color:var(--accent); background:var(--bg-hover); }
130 .theme-swatch { width:16px; height:16px; border-radius:50%; border:1px solid var(--border); }
131 .swatch-light { background:#fff; }
132 .swatch-dark { background:#0d1117; }
133 .swatch-dark-blue { background:#1a2332; }
134 `
135
136 const scripts = `
137 (function () {
138 var basePath = ${JSON.stringify(pathPrefix)};
139
140 // Theme picker
141 document.querySelectorAll('.theme-option').forEach(function (btn) {
142 btn.addEventListener('click', function () {
143 var theme = btn.getAttribute('data-theme');
144 document.documentElement.setAttribute('data-theme', theme);
145 localStorage.setItem('hn-theme', theme);
146 document.querySelectorAll('.theme-option').forEach(function (b) { b.classList.remove('theme-active'); });
147 btn.classList.add('theme-active');
148 window.fetch(basePath + '/api/user/profile', {
149 method: 'PATCH',
150 headers: { 'Content-Type': 'application/json' },
151 body: JSON.stringify({ theme: theme })
152 }).catch(function () {});
153 });
154 });
155
156 // Copy public key
157 var btnPub = document.getElementById('btn-copy-pubkey');
158 if (btnPub) btnPub.addEventListener('click', function () {
159 var pk = window.HnIdentity && window.HnIdentity.getPublicKey();
160 if (pk) { navigator.clipboard.writeText(pk); btnPub.textContent = 'Copied!'; setTimeout(function () { btnPub.textContent = 'Copy Public Key'; }, 2000); }
161 });
162
163 // Copy keypair
164 var btnKp = document.getElementById('btn-copy-keypair');
165 if (btnKp) btnKp.addEventListener('click', function () {
166 var kp = window.HnIdentity && window.HnIdentity.getKeypair();
167 if (kp) { navigator.clipboard.writeText(kp); btnKp.textContent = 'Copied!'; setTimeout(function () { btnKp.textContent = 'Copy Keypair'; }, 2000); }
168 });
169
170 // Show keypair
171 var btnShow = document.getElementById('btn-show-keypair');
172 var kpDisplay = document.getElementById('keypair-display');
173 if (btnShow && kpDisplay) btnShow.addEventListener('click', function () {
174 if (kpDisplay.style.display === 'none') {
175 var kp = window.HnIdentity && window.HnIdentity.getKeypair();
176 var pk = window.HnIdentity && window.HnIdentity.getPublicKey();
177 kpDisplay.innerHTML = '<div style="background:var(--bg-secondary);padding:12px;border-radius:6px;font-family:monospace;font-size:0.7rem;word-break:break-all">'
178 + '<div><strong>Public Key:</strong></div><div style="margin:4px 0">' + (pk || 'none') + '</div>'
179 + '<div style="margin-top:8px"><strong style="color:var(--danger)">Full Keypair (KEEP SECRET):</strong></div>'
180 + '<div style="margin:4px 0">' + (kp || 'none') + '</div></div>';
181 kpDisplay.style.display = 'block';
182 btnShow.textContent = 'Hide Keypair';
183 } else {
184 kpDisplay.style.display = 'none';
185 btnShow.textContent = 'Show Keypair';
186 }
187 });
188
189 // Export keypair
190 var btnExport = document.getElementById('btn-export-keypair');
191 if (btnExport) btnExport.addEventListener('click', function () {
192 var data = window.HnIdentity && window.HnIdentity.exportKeypair();
193 if (!data) return;
194 var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
195 var a = document.createElement('a');
196 a.href = URL.createObjectURL(blob);
197 a.download = 'hypernote-identity.json';
198 a.click();
199 });
200
201 // Import keypair
202 var btnImport = document.getElementById('btn-import-keypair');
203 var importForm = document.getElementById('import-form');
204 if (btnImport && importForm) btnImport.addEventListener('click', function () {
205 importForm.style.display = importForm.style.display === 'none' ? 'block' : 'none';
206 });
207 var btnDoImport = document.getElementById('btn-do-import');
208 if (btnDoImport) btnDoImport.addEventListener('click', function () {
209 var val = document.getElementById('import-input').value.trim();
210 if (val.length !== 132) { alert('Keypair must be 132 characters'); return; }
211 try {
212 window.HnIdentity.importKeypair(val);
213 window.location.reload();
214 } catch (e) { alert('Invalid keypair: ' + e.message); }
215 });
216
217 // Username set/change
218 function usernameAction () {
219 var current = ${JSON.stringify(username)};
220 var newName = prompt(current ? 'Change username (current: @' + current + '):' : 'Set your username:', current || '');
221 if (newName === null) return;
222 newName = newName.trim().toLowerCase();
223 if (!newName) return;
224 window.fetch(basePath + '/api/user/username', {
225 method: 'POST',
226 headers: { 'Content-Type': 'application/json' },
227 body: JSON.stringify({ username: newName })
228 }).then(function (r) {
229 if (r.ok) window.location.reload();
230 else r.json().then(function (d) { alert(d.error || 'Failed'); });
231 }).catch(function (e) { alert('Error: ' + e.message); });
232 }
233 var btnSetUser = document.getElementById('btn-set-username');
234 var btnChangeUser = document.getElementById('btn-change-username');
235 if (btnSetUser) btnSetUser.addEventListener('click', usernameAction);
236 if (btnChangeUser) btnChangeUser.addEventListener('click', usernameAction);
237
238 // Edit name
239 var btnEditName = document.getElementById('btn-edit-name');
240 if (btnEditName) btnEditName.addEventListener('click', function () {
241 var newName = prompt('Display name:', ${JSON.stringify(displayName)});
242 if (newName === null) return;
243 window.fetch(basePath + '/api/user/profile', {
244 method: 'PATCH',
245 headers: { 'Content-Type': 'application/json' },
246 body: JSON.stringify({ name: newName.trim() })
247 }).then(function (r) { if (r.ok) window.location.reload(); })
248 .catch(function () {});
249 });
250
251 // Logout
252 var btnLogout = document.getElementById('btn-logout');
253 if (btnLogout) btnLogout.addEventListener('click', function () {
254 if (confirm('Clear your identity? You will lose access unless you have exported your keypair.')) {
255 window.HnIdentity.clear();
256 window.location.href = basePath + '/';
257 }
258 });
259 })();
260 `
261
262 return renderPage('Profile', content, { pathPrefix, activePage: 'profile', css, scripts })
263}
lib/views/search-ui.js +49 -0
……@@ -0,0 +1,49 @@
1/**
2 * Search Results — Full-page search results view
3 *
4 * SSR view showing search results across notebooks.
5 */
6
7import { renderPage } from '../page-layout.js'
8
9function esc (s) {
10 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13/**
14 * Render the search results page
15 *
16 * @param {string} query - Search query
17 * @param {Array} results - Search result objects
18 * @param {Object} opts - { pathPrefix }
19 * @returns {string} Full HTML page
20 */
21export function renderSearchPage (query, results, opts = {}) {
22 const pathPrefix = opts.pathPrefix || ''
23
24 let content = ''
25 content += '<h1>Search</h1>'
26 content += `<form method="get" action="${pathPrefix}/search" style="margin-bottom:24px">`
27 content += `<input type="search" name="q" value="${esc(query)}" placeholder="Search notes..." autofocus style="max-width:500px">`
28 content += '</form>'
29
30 if (!query) {
31 content += '<p class="meta">Enter a search term above.</p>'
32 } else if (results.length === 0) {
33 content += `<p class="meta">No results for "${esc(query)}"</p>`
34 } else {
35 content += `<p class="meta">${results.length} result${results.length !== 1 ? 's' : ''} for "${esc(query)}"</p>`
36 for (const r of results) {
37 const href = `${pathPrefix}/notebooks/${encodeURIComponent(r.notebook)}/notes/${encodeURIComponent(r.slug || r.id)}`
38 content += `<a class="card" href="${href}">`
39 content += `<h3>${esc(r.title || 'Untitled')}</h3>`
40 content += `<div class="desc">${esc(r.notebook)}</div>`
41 if (r.snippet) {
42 content += `<div class="desc">${esc(r.snippet.substring(0, 200))}</div>`
43 }
44 content += '</a>'
45 }
46 }
47
48 return renderPage('Search', content, { pathPrefix, activePage: '' })
49}
lib/views/user-ui.js +42 -0
……@@ -0,0 +1,42 @@
1/**
2 * User Public Profile — List of user's public notebooks
3 *
4 * SSR view showing a user's public-facing profile.
5 */
6
7import { renderPage } from '../page-layout.js'
8
9function esc (s) {
10 return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13/**
14 * Render user public profile page
15 *
16 * @param {Object} user - User object with name, username
17 * @param {Array} notebooks - Public notebooks
18 * @param {Object} opts - { pathPrefix }
19 * @returns {string} Full HTML page
20 */
21export function renderUserPage (user, notebooks, opts = {}) {
22 const pathPrefix = opts.pathPrefix || ''
23
24 let content = ''
25 content += `<h1>@${esc(user.username || 'anonymous')}</h1>`
26 content += `<p class="meta">${esc(user.name || 'Anonymous')}</p>`
27
28 if (notebooks.length === 0) {
29 content += '<p class="meta">No public notebooks.</p>'
30 } else {
31 content += `<p class="meta">${notebooks.length} public notebook${notebooks.length !== 1 ? 's' : ''}</p>`
32 for (const nb of notebooks) {
33 content += `<a class="card" href="${pathPrefix}/notebooks/${encodeURIComponent(nb.slug)}">`
34 content += `<h3>${esc(nb.name || nb.slug)}</h3>`
35 if (nb.description) content += `<div class="desc">${esc(nb.description)}</div>`
36 content += `<div class="desc">${nb.noteCount || 0} notes</div>`
37 content += '</a>'
38 }
39 }
40
41 return renderPage('@' + (user.username || 'user'), content, { pathPrefix, activePage: '' })
42}
scripts/backup.sh +207 -0
……@@ -0,0 +1,207 @@
1#!/bin/bash
2#
3# Hypernote Backup — Export all notebooks to portable format
4#
5# Usage:
6# ./scripts/backup.sh [BASE_URL] [OUTPUT_DIR]
7#
8# Output structure:
9# backup/
10# ├── manifest.json
11# ├── users.json
12# ├── notebooks/
13# │ ├── my-notebook/
14# │ │ ├── metadata.json
15# │ │ ├── notes/
16# │ │ │ ├── my-note.md
17# │ │ │ ├── my-note.meta.json
18# │ │ │ └── ...
19# │ │ └── media/
20# │ │ ├── abc123...def.png
21# │ │ └── ...
22
23set -euo pipefail
24
25BASE_URL="${1:-https://7rnx.net/hypernote}"
26DATE=$(date +%Y-%m-%d)
27OUTPUT="${2:-$HOME/hypernote-backups/$DATE}"
28BASE_URL="${BASE_URL%/}"
29
30echo "=== Hypernote Backup ==="
31echo "Source: $BASE_URL"
32echo "Output: $OUTPUT"
33echo ""
34
35if ! curl -sf "$BASE_URL/api/health" > /dev/null 2>&1; then
36 echo "ERROR: Cannot reach $BASE_URL/api/health"
37 exit 1
38fi
39
40VERSION=$(curl -sf "$BASE_URL/api/health" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
41echo "Server version: $VERSION"
42mkdir -p "$OUTPUT"
43
44cat > "$OUTPUT/manifest.json" << EOF
45{
46 "format": "hypernote-backup-v1",
47 "source": "$BASE_URL",
48 "serverVersion": "$VERSION",
49 "exportedAt": "$(date -Is)",
50 "hostname": "$(hostname)"
51}
52EOF
53
54# Export users
55echo "[1/4] Exporting users..."
56curl -sf "$BASE_URL/api/admin/users" > "$OUTPUT/users.json" 2>/dev/null || echo '{"users":[]}' > "$OUTPUT/users.json"
57
58# Get notebook list
59echo "[2/4] Fetching notebook list..."
60curl -sf "$BASE_URL/api/notebooks" > "$OUTPUT/notebooks-index.json"
61
62SLUGS=$(python3 -c "
63import json
64data = json.load(open('$OUTPUT/notebooks-index.json'))
65nbs = data.get('notebooks', data) if isinstance(data, dict) else data
66for nb in nbs:
67 print(nb.get('slug', ''))
68" 2>/dev/null)
69
70if [ -z "$SLUGS" ]; then
71 echo "No notebooks found. Done."
72 exit 0
73fi
74
75NB_COUNT=$(echo "$SLUGS" | wc -l)
76echo "Found $NB_COUNT notebooks"
77
78# Export each notebook
79echo "[3/4] Exporting notebooks..."
80CURRENT=0
81TOTAL_NOTES=0
82TOTAL_MEDIA=0
83
84for SLUG in $SLUGS; do
85 CURRENT=$((CURRENT + 1))
86 NB_DIR="$OUTPUT/notebooks/$SLUG"
87 NOTES_DIR="$NB_DIR/notes"
88 MEDIA_DIR="$NB_DIR/media"
89 mkdir -p "$NOTES_DIR" "$MEDIA_DIR"
90
91 # Notebook metadata
92 curl -sf "$BASE_URL/api/notebooks/$SLUG" > "$NB_DIR/metadata.json" 2>/dev/null || echo "{}" > "$NB_DIR/metadata.json"
93
94 # Get notes as raw JSON file first, then process with python
95 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"
96
97 # Parse and save each note
98 COUNTS=$(python3 << 'PYEOF'
99import json, os, re, sys
100
101nb_dir = os.environ.get("NB_DIR", ".")
102notes_dir = os.environ.get("NOTES_DIR", ".")
103
104with open(os.path.join(nb_dir, "notes-raw.json")) as f:
105 data = json.load(f)
106
107notes = data.get("notes", [])
108media_hashes = set()
109
110for note in notes:
111 nid = note.get("slug") or note.get("id", "unknown")
112 # Sanitize filename
113 nid = re.sub(r'[^\w\-]', '_', nid)
114 ntype = note.get("type", "markdown")
115 content = note.get("content", "")
116
117 # Save content
118 if ntype == "markdown":
119 with open(os.path.join(notes_dir, nid + ".md"), "w") as f:
120 f.write(content)
121 else:
122 with open(os.path.join(notes_dir, nid + ".json"), "w") as f:
123 f.write(content if content else "{}")
124
125 # Save metadata sidecar
126 meta = {
127 "id": note.get("id"),
128 "slug": note.get("slug"),
129 "title": note.get("title"),
130 "tags": note.get("tags", []),
131 "type": ntype,
132 "createdAt": note.get("createdAt"),
133 "updatedAt": note.get("updatedAt")
134 }
135 with open(os.path.join(notes_dir, nid + ".meta.json"), "w") as f:
136 json.dump(meta, f, indent=2)
137
138 # Extract media hashes
139 for m in re.findall(r'/media/([a-f0-9]{64})', content):
140 media_hashes.add(m)
141
142# Write media manifest
143with open(os.path.join(nb_dir, "media-manifest.json"), "w") as f:
144 json.dump(sorted(media_hashes), f, indent=2)
145
146print(f"{len(notes)} {len(media_hashes)}")
147PYEOF
148 )
149
150 NOTE_COUNT=$(echo "$COUNTS" | awk '{print $1}')
151 MEDIA_COUNT=$(echo "$COUNTS" | awk '{print $2}')
152 TOTAL_NOTES=$((TOTAL_NOTES + NOTE_COUNT))
153
154 # Clean up raw json
155 rm -f "$NB_DIR/notes-raw.json"
156
157 echo " [$CURRENT/$NB_COUNT] $SLUG: $NOTE_COUNT notes, $MEDIA_COUNT media refs"
158done
159
160# Download media files
161echo "[4/4] Downloading media files..."
162for SLUG in $SLUGS; do
163 NB_DIR="$OUTPUT/notebooks/$SLUG"
164 MEDIA_DIR="$NB_DIR/media"
165 MANIFEST="$NB_DIR/media-manifest.json"
166
167 if [ ! -f "$MANIFEST" ]; then continue; fi
168
169 HASHES=$(python3 -c "import json; [print(h) for h in json.load(open('$MANIFEST'))]" 2>/dev/null || true)
170
171 for HASH in $HASHES; do
172 if [ -z "$HASH" ]; then continue; fi
173 if [ -f "$MEDIA_DIR/$HASH" ]; then continue; fi
174
175 HTTP_CODE=$(curl -sf -o "$MEDIA_DIR/$HASH.tmp" -w "%{http_code}" \
176 "$BASE_URL/api/notebooks/$SLUG/media/$HASH" 2>/dev/null || echo "000")
177
178 if [ "$HTTP_CODE" = "200" ] && [ -f "$MEDIA_DIR/$HASH.tmp" ]; then
179 # Detect extension from content-type
180 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}')
181 case "$CTYPE" in
182 image/png) EXT=".png" ;;
183 image/jpeg) EXT=".jpg" ;;
184 image/gif) EXT=".gif" ;;
185 image/webp) EXT=".webp" ;;
186 video/mp4) EXT=".mp4" ;;
187 video/webm) EXT=".webm" ;;
188 audio/mpeg) EXT=".mp3" ;;
189 *) EXT="" ;;
190 esac
191 mv "$MEDIA_DIR/$HASH.tmp" "$MEDIA_DIR/$HASH$EXT"
192 TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
193 else
194 rm -f "$MEDIA_DIR/$HASH.tmp"
195 echo " WARN: Failed to download media $HASH ($HTTP_CODE)"
196 fi
197 done
198done
199
200# Summary
201echo ""
202echo "=== Backup Complete ==="
203echo "Location: $OUTPUT"
204echo "Notebooks: $NB_COUNT"
205echo "Notes: $TOTAL_NOTES"
206echo "Media: $TOTAL_MEDIA files"
207echo "Size: $(du -sh "$OUTPUT" | cut -f1)"
scripts/compact-autobases.js +161 -0
……@@ -0,0 +1,161 @@
1#!/usr/bin/env node
2
3/**
4 * Compact hypernote Autobases — remove duplicate entries from sync loop bloat.
5 *
6 * Approach:
7 * 1. Open each SharedNotebook and SharedRegistry Autobase
8 * 2. Read the current view state (already deduplicated by Autobase linearization)
9 * 3. Check how many entries the local writer has vs unique entries in view
10 * 4. Report what can be compacted
11 *
12 * The actual compaction: delete the multiwriter config from the registry,
13 * which causes the server to create fresh Autobases on next start.
14 * The event-driven sync re-populates from local Hyperdrives (notes) and
15 * identity registry (users).
16 *
17 * Usage:
18 * node scripts/compact-autobases.js <storage-path> [--apply]
19 */
20
21import Corestore from 'corestore'
22import Hyperbee from 'hyperbee'
23import Autobase from 'autobase'
24
25const args = process.argv.slice(2)
26const storagePath = args[0] || process.env.HOME + '/.config/hypernote/server-data'
27const apply = args.includes('--apply')
28
29console.log('=== Autobase Compaction ===')
30console.log('Storage:', storagePath)
31console.log('Mode:', apply ? 'APPLY (will modify data)' : 'DRY RUN (read-only)')
32console.log('')
33
34const store = new Corestore(storagePath)
35await store.ready()
36
37// Open the main registry to find multiwriter config
38const registryCore = store.get({ name: 'registry' })
39const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
40await registry.ready()
41
42const mwConfig = await registry.get('multiwriter-config')
43if (!mwConfig || !mwConfig.value) {
44 console.log('No multiwriter config found — nothing to compact.')
45 await store.close()
46 process.exit(0)
47}
48
49const bootstrapKey = mwConfig.value.registryBootstrapKey
50console.log('Multiwriter config:', JSON.stringify(mwConfig.value, null, 2))
51console.log('')
52
53// Open SharedRegistry to inspect its state
54const regStore = store.namespace('shared-registry')
55const regBase = new Autobase(regStore, Buffer.from(bootstrapKey, 'hex'), {
56 open: (store) => new Hyperbee(store.get('view'), {
57 keyEncoding: 'utf-8',
58 valueEncoding: 'json'
59 }),
60 apply: async (nodes, view, base) => {
61 for (const node of nodes) {
62 const op = node.value
63 if (!op || !op.key) continue
64 await view.put(op.key, op.value)
65 }
66 },
67 valueEncoding: 'json'
68})
69await regBase.ready()
70await regBase.update()
71
72// Count view entries
73let viewEntries = 0
74let notebooks = []
75for await (const entry of regBase.view.createReadStream()) {
76 viewEntries++
77 if (entry.key.startsWith('notebook:')) {
78 const nb = entry.value
79 if (nb && nb.slug) notebooks.push({ slug: nb.slug, autobaseKey: nb.autobaseKey, shared: nb.shared })
80 }
81}
82
83const localWriterLen = regBase.local ? regBase.local.length : 0
84console.log('SharedRegistry:')
85console.log(' View entries (unique):', viewEntries)
86console.log(' Local writer blocks:', localWriterLen)
87console.log(' Bloat ratio:', localWriterLen > 0 ? (localWriterLen / Math.max(viewEntries, 1)).toFixed(1) + 'x' : 'N/A')
88console.log(' Notebooks:', notebooks.map(n => n.slug).join(', '))
89console.log('')
90
91// Inspect each SharedNotebook
92for (const nb of notebooks) {
93 if (!nb.shared || !nb.autobaseKey) continue
94
95 const nbStore = store.namespace('shared-notebook-' + nb.slug)
96 try {
97 const nbBase = new Autobase(nbStore, Buffer.from(nb.autobaseKey, 'hex'), {
98 open: (store) => new Hyperbee(store.get('view'), {
99 keyEncoding: 'utf-8',
100 valueEncoding: 'json'
101 }),
102 apply: async (nodes, view, base) => {
103 for (const node of nodes) {
104 const op = node.value
105 if (!op || !op.key) continue
106 await view.put(op.key, op.value)
107 }
108 },
109 valueEncoding: 'json'
110 })
111 await nbBase.ready()
112 await nbBase.update()
113
114 let nbViewEntries = 0
115 for await (const entry of nbBase.view.createReadStream()) {
116 nbViewEntries++
117 }
118
119 const nbLocalLen = nbBase.local ? nbBase.local.length : 0
120 console.log(`SharedNotebook [${nb.slug}]:`)
121 console.log(' View entries (unique):', nbViewEntries)
122 console.log(' Local writer blocks:', nbLocalLen)
123 console.log(' Bloat ratio:', nbLocalLen > 0 ? (nbLocalLen / Math.max(nbViewEntries, 1)).toFixed(1) + 'x' : 'N/A')
124
125 await nbBase.close()
126 } catch (err) {
127 console.log(`SharedNotebook [${nb.slug}]: ERROR — ${err.message}`)
128 }
129}
130
131console.log('')
132
133if (!apply) {
134 console.log('=== Dry run complete. Use --apply to compact. ===')
135 console.log('')
136 console.log('Compaction will:')
137 console.log(' 1. Delete multiwriter-config from registry')
138 console.log(' 2. Server creates fresh Autobases on next start')
139 console.log(' 3. Event-driven sync re-populates from local data')
140 console.log(' 4. Old Autobase cores become orphaned (reclaimable later)')
141 await regBase.close()
142 await store.close()
143 process.exit(0)
144}
145
146// APPLY compaction
147console.log('=== Applying compaction ===')
148console.log('Deleting multiwriter-config from registry...')
149await registry.del('multiwriter-config')
150console.log('Done. Multiwriter config removed.')
151console.log('')
152console.log('On next server start:')
153console.log(' - Fresh Autobases will be created')
154console.log(' - Mirror sync will re-populate from local Hyperdrives')
155console.log(' - Memory usage will drop significantly')
156console.log('')
157console.log('Old Autobase cores are still on disk but unused.')
158console.log('Run hyper-inspector to verify storage savings after restart.')
159
160await regBase.close()
161await store.close()
scripts/extract-media.js +169 -0
……@@ -0,0 +1,169 @@
1#!/usr/bin/env node
2
3/**
4 * Extract media blobs from a hypernote corestore backup.
5 * Uses RocksDB read-only access to bypass device-file locks.
6 *
7 * Usage:
8 * node scripts/extract-media.js <storage-path> <output-dir> [hash1 hash2 ...]
9 *
10 * If no hashes given, extracts ALL media from the index.
11 */
12
13import RocksDB from 'rocksdb-native'
14import Corestore from 'corestore'
15import Hyperbee from 'hyperbee'
16import Hyperblobs from 'hyperblobs'
17import fs from 'fs'
18import path from 'path'
19
20const args = process.argv.slice(2)
21if (args.length < 2) {
22 console.log('Usage: node scripts/extract-media.js <storage-path> <output-dir> [hash1 hash2 ...]')
23 process.exit(1)
24}
25
26const storagePath = args[0]
27const outputDir = args[1]
28const requestedHashes = args.slice(2)
29
30// Resolve the actual corestore path
31let corestorePath = storagePath
32if (fs.existsSync(path.join(storagePath, 'corestore'))) {
33 corestorePath = path.join(storagePath, 'corestore')
34}
35
36if (!fs.existsSync(corestorePath)) {
37 console.error('Storage path not found:', corestorePath)
38 process.exit(1)
39}
40
41fs.mkdirSync(outputDir, { recursive: true })
42
43console.log('Opening corestore:', storagePath)
44console.log('Output directory:', outputDir)
45
46// Workaround: remove device-file lock from the backup copy
47// This is safe because it's a backup, not the live storage
48const dbPath = path.join(corestorePath, 'db')
49const lockPath = path.join(dbPath, 'LOCK')
50const deviceFiles = []
51
52// Find and temporarily rename LOCK files so Corestore can open
53for (const dir of [dbPath, path.join(storagePath, 'db')]) {
54 const lp = path.join(dir, 'LOCK')
55 if (fs.existsSync(lp)) {
56 const tmp = lp + '.bak'
57 fs.renameSync(lp, tmp)
58 deviceFiles.push({ from: tmp, to: lp })
59 }
60}
61
62let store
63try {
64 store = new Corestore(storagePath)
65 await store.ready()
66} catch (err) {
67 // Restore lock files on failure
68 for (const { from, to } of deviceFiles) {
69 try { fs.renameSync(from, to) } catch {}
70 }
71 console.error('Failed to open corestore:', err.message)
72 process.exit(1)
73}
74
75const MIME_TO_EXT = {
76 'image/png': '.png',
77 'image/jpeg': '.jpg',
78 'image/gif': '.gif',
79 'image/webp': '.webp',
80 'image/svg+xml': '.svg',
81 'video/mp4': '.mp4',
82 'video/webm': '.webm',
83 'audio/mpeg': '.mp3',
84 'audio/ogg': '.ogg'
85}
86
87try {
88 // Open MediaStore's namespace and cores
89 const mediaNs = store.namespace('media-store')
90 const blobCore = mediaNs.get({ name: 'blobs' })
91 const indexCore = mediaNs.get({ name: 'index' })
92
93 await blobCore.ready()
94 await indexCore.ready()
95
96 console.log('Blob core length:', blobCore.length)
97 console.log('Index core length:', indexCore.length)
98
99 const blobs = new Hyperblobs(blobCore)
100 const index = new Hyperbee(indexCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
101 await index.ready()
102
103 let hashes = [...requestedHashes]
104
105 // If no specific hashes requested, enumerate all from index
106 if (hashes.length === 0) {
107 console.log('\nEnumerating all media entries...')
108 for await (const entry of index.createReadStream()) {
109 const key = entry.key
110 if (key.startsWith('blob:')) {
111 hashes.push(key.substring(5))
112 }
113 }
114 console.log(`Found ${hashes.length} media entries in index`)
115 }
116
117 let extracted = 0
118 let failed = 0
119 let skipped = 0
120
121 for (const hash of hashes) {
122 const entry = await index.get('blob:' + hash)
123 if (!entry || !entry.value) {
124 console.log(` SKIP ${hash.substring(0, 16)}... (not in index)`)
125 skipped++
126 continue
127 }
128
129 const { blobId, size, mimeType } = entry.value
130 const ext = MIME_TO_EXT[mimeType] || ''
131 const outFile = path.join(outputDir, hash + ext)
132
133 if (fs.existsSync(outFile)) {
134 console.log(` SKIP ${hash.substring(0, 16)}...${ext} (already exists)`)
135 skipped++
136 continue
137 }
138
139 try {
140 const data = await blobs.get(blobId)
141 if (!data) {
142 console.log(` FAIL ${hash.substring(0, 16)}... (blob not found for blobId ${blobId})`)
143 failed++
144 continue
145 }
146
147 fs.writeFileSync(outFile, data)
148 console.log(` OK ${hash.substring(0, 16)}...${ext} (${formatBytes(data.length)}, ${mimeType})`)
149 extracted++
150 } catch (err) {
151 console.log(` FAIL ${hash.substring(0, 16)}... (${err.message})`)
152 failed++
153 }
154 }
155
156 console.log(`\nDone: ${extracted} extracted, ${skipped} skipped, ${failed} failed`)
157} finally {
158 try { await store.close() } catch {}
159 // Restore lock files
160 for (const { from, to } of deviceFiles) {
161 try { fs.renameSync(from, to) } catch {}
162 }
163}
164
165function formatBytes (b) {
166 if (b < 1024) return b + ' B'
167 if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
168 return (b / (1024 * 1024)).toFixed(1) + ' MB'
169}
scripts/restore-direct.js +211 -0
……@@ -0,0 +1,211 @@
1#!/usr/bin/env node
2
3/**
4 * Direct restore — import notes from portable backup directly into Hyperdrives.
5 * Bypasses HTTP API (no auth needed). Server must be STOPPED.
6 *
7 * Usage:
8 * node scripts/restore-direct.js <backup-dir> [storage-path]
9 */
10
11import Corestore from 'corestore'
12import Hyperbee from 'hyperbee'
13import Hyperdrive from 'hyperdrive'
14import fs from 'fs'
15import path from 'path'
16import crypto from 'crypto'
17
18const args = process.argv.slice(2)
19const backupDir = args[0]
20const storagePath = args[1] || process.env.HOME + '/.config/hypernote/server-data'
21
22if (!backupDir || !fs.existsSync(backupDir)) {
23 console.log('Usage: node scripts/restore-direct.js <backup-dir> [storage-path]')
24 process.exit(1)
25}
26
27console.log('=== Direct Restore ===')
28console.log('Backup:', backupDir)
29console.log('Storage:', storagePath)
30console.log('')
31
32const store = new Corestore(storagePath)
33await store.ready()
34
35const registryCore = store.get({ name: 'registry' })
36const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
37await registry.ready()
38
39// Build drive index from registry
40const driveIndex = new Map()
41for await (const entry of registry.createReadStream({ gte: 'drive:', lt: 'drive:\xff' })) {
42 const slug = entry.key.replace('drive:', '')
43 driveIndex.set(slug, entry.value)
44}
45
46let totalRestored = 0
47let totalSkipped = 0
48let totalCreated = 0
49
50const notebooksDir = path.join(backupDir, 'notebooks')
51if (!fs.existsSync(notebooksDir)) {
52 console.log('No notebooks directory found')
53 await store.close()
54 process.exit(1)
55}
56
57for (const slug of fs.readdirSync(notebooksDir)) {
58 const nbDir = path.join(notebooksDir, slug)
59 if (!fs.statSync(nbDir).isDirectory()) continue
60
61 const notesDir = path.join(nbDir, 'notes')
62 if (!fs.existsSync(notesDir)) continue
63
64 console.log(`\n=== ${slug} ===`)
65
66 // Get or create drive for this notebook
67 const driveInfo = driveIndex.get(slug)
68 let drive
69 if (driveInfo && driveInfo.key) {
70 // Skip remote drives — we can only write to local ones
71 if (driveInfo.isLocal === false) {
72 console.log(' SKIP: remote notebook (read-only)')
73 continue
74 }
75 drive = new Hyperdrive(store.namespace('notebook-' + slug), Buffer.from(driveInfo.key, 'hex'))
76 await Promise.race([
77 drive.ready(),
78 new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 10000))
79 ]).catch(() => { drive = null })
80 } else {
81 // Create new drive for this notebook
82 drive = new Hyperdrive(store.namespace('notebook-' + slug))
83 await drive.ready()
84 const keyHex = drive.key.toString('hex')
85 const dkeyHex = drive.discoveryKey.toString('hex')
86 await registry.put('drive:' + slug, { key: keyHex, discoveryKey: dkeyHex, slug, isLocal: true })
87 driveIndex.set(slug, { key: keyHex })
88 console.log(' Created drive for: ' + slug)
89 totalCreated++
90 }
91
92 // Check if drive opened and is writable
93 if (!drive) {
94 console.log(' SKIP: drive failed to open')
95 continue
96 }
97 if (!drive.db.feed.writable) {
98 console.log(' SKIP: drive is read-only (remote notebook)')
99 continue
100 }
101
102 // Read existing note IDs from drive
103 const existingIds = new Set()
104 try {
105 for await (const f of drive.readdir('/notes')) {
106 if (f.endsWith('.json')) existingIds.add(f.replace('.json', ''))
107 }
108 } catch {}
109
110 // Find all meta files in backup
111 const metaFiles = fs.readdirSync(notesDir).filter(f => f.endsWith('.meta.json'))
112
113 for (const metaFile of metaFiles) {
114 const meta = JSON.parse(fs.readFileSync(path.join(notesDir, metaFile), 'utf-8'))
115 const noteId = meta.id
116 const noteSlug = meta.slug
117
118 // Skip if already exists
119 if (existingIds.has(noteId)) {
120 totalSkipped++
121 continue
122 }
123
124 // Read content
125 const baseName = metaFile.replace('.meta.json', '')
126 let content = ''
127 const mdFile = path.join(notesDir, baseName + '.md')
128 const jsonFile = path.join(notesDir, baseName + '.json')
129 if (fs.existsSync(mdFile)) {
130 content = fs.readFileSync(mdFile, 'utf-8')
131 } else if (fs.existsSync(jsonFile)) {
132 content = fs.readFileSync(jsonFile, 'utf-8')
133 }
134
135 // Write note to drive
136 const noteObj = {
137 id: noteId,
138 slug: noteSlug || baseName,
139 title: meta.title || 'Untitled',
140 content,
141 tags: meta.tags || [],
142 type: meta.type || 'markdown',
143 createdAt: meta.createdAt || Date.now(),
144 updatedAt: meta.updatedAt || Date.now()
145 }
146
147 await drive.put('/notes/' + noteId + '.json', Buffer.from(JSON.stringify(noteObj, null, 2)))
148 console.log(' + ' + (meta.title || noteId))
149 totalRestored++
150 }
151
152 // Update notebook metadata noteCount
153 const nbMeta = await registry.get('notebook:' + slug)
154 if (nbMeta && nbMeta.value) {
155 const updated = { ...nbMeta.value, noteCount: existingIds.size + totalRestored }
156 await registry.put('notebook:' + slug, updated)
157 }
158}
159
160// Restore media from media-all
161const mediaAllDir = path.join(backupDir, 'media-all')
162if (fs.existsSync(mediaAllDir)) {
163 console.log('\n=== Restoring media ===')
164
165 // Open MediaStore
166 const mediaStore = store.namespace('media-store')
167 const blobCore = mediaStore.get({ name: 'blobs' })
168 const indexCore = mediaStore.get({ name: 'index' })
169 await blobCore.ready()
170 await indexCore.ready()
171
172 // Dynamic import for Hyperblobs
173 const { default: Hyperblobs } = await import('hyperblobs')
174 const blobs = new Hyperblobs(blobCore)
175 const index = new Hyperbee(indexCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
176 await index.ready()
177
178 let mediaRestored = 0
179 let mediaSkipped = 0
180
181 for (const file of fs.readdirSync(mediaAllDir)) {
182 const ext = path.extname(file)
183 const hash = path.basename(file, ext)
184
185 // Skip if already in index
186 const existing = await index.get('blob:' + hash)
187 if (existing) { mediaSkipped++; continue }
188
189 const MIME = {
190 '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
191 '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
192 '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg'
193 }
194 const mimeType = MIME[ext] || 'application/octet-stream'
195
196 const data = fs.readFileSync(path.join(mediaAllDir, file))
197 const blobId = await blobs.put(data)
198 await index.put('blob:' + hash, { blobId, size: data.length, mimeType, createdAt: Date.now() })
199 console.log(' + ' + file + ' (' + (data.length / 1024).toFixed(0) + 'KB)')
200 mediaRestored++
201 }
202
203 console.log(`\nMedia: ${mediaRestored} restored, ${mediaSkipped} skipped (already exist)`)
204}
205
206console.log(`\n=== Restore Complete ===`)
207console.log(`Notes restored: ${totalRestored}`)
208console.log(`Notes skipped: ${totalSkipped} (already exist)`)
209console.log(`Drives created: ${totalCreated}`)
210
211await store.close()
scripts/restore.sh +204 -0
……@@ -0,0 +1,204 @@
1#!/bin/bash
2#
3# Hypernote Restore — Import notes and media from a portable backup
4#
5# Usage:
6# ./scripts/restore.sh [BACKUP_DIR] [BASE_URL]
7#
8# Reads the backup structure created by backup.sh and pushes everything
9# back into hypernote via the API.
10
11set -euo pipefail
12
13BACKUP="${1:-$HOME/hypernote-backups/2026-04-06-pre-compaction}"
14BASE_URL="${2:-http://localhost:4040/hypernote}"
15BASE_URL="${BASE_URL%/}"
16
17echo "=== Hypernote Restore ==="
18echo "Backup: $BACKUP"
19echo "Target: $BASE_URL"
20echo ""
21
22if [ ! -d "$BACKUP/notebooks" ]; then
23 echo "ERROR: No notebooks directory in $BACKUP"
24 exit 1
25fi
26
27if ! curl -sf "$BASE_URL/api/health" > /dev/null 2>&1; then
28 echo "ERROR: Cannot reach $BASE_URL/api/health"
29 exit 1
30fi
31
32echo "WARNING: This will create notebooks and notes that don't already exist."
33echo "Existing notes with the same ID will be SKIPPED (not overwritten)."
34echo ""
35read -p "Continue? [y/N] " confirm
36if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
37 echo "Aborted."
38 exit 0
39fi
40
41TOTAL_NOTES=0
42TOTAL_MEDIA=0
43SKIPPED=0
44
45for NB_DIR in "$BACKUP"/notebooks/*/; do
46 SLUG=$(basename "$NB_DIR")
47 echo ""
48 echo "=== Notebook: $SLUG ==="
49
50 # Read notebook metadata
51 META_FILE="$NB_DIR/metadata.json"
52 if [ -f "$META_FILE" ]; then
53 NAME=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('name','$SLUG'))" 2>/dev/null || echo "$SLUG")
54 DESC=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('description',''))" 2>/dev/null || echo "")
55 VIS=$(python3 -c "import json; m=json.load(open('$META_FILE')); print(m.get('visibility','public'))" 2>/dev/null || echo "public")
56 else
57 NAME="$SLUG"
58 DESC=""
59 VIS="public"
60 fi
61
62 # Check if notebook exists
63 HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG" 2>/dev/null || echo "000")
64 if [ "$HTTP_CODE" != "200" ]; then
65 echo " Creating notebook: $SLUG"
66 curl -sf -X POST "$BASE_URL/api/notebooks" \
67 -H "Content-Type: application/json" \
68 -d "{\"slug\":\"$SLUG\",\"name\":$(python3 -c "import json; print(json.dumps('$NAME'))"),\"description\":$(python3 -c "import json; print(json.dumps('$DESC'))"),\"visibility\":\"$VIS\"}" \
69 > /dev/null 2>&1 || echo " WARN: Failed to create notebook $SLUG (may need auth)"
70 else
71 echo " Notebook exists: $SLUG"
72 fi
73
74 # Restore notes
75 if [ -d "$NB_DIR/notes" ]; then
76 for META in "$NB_DIR"/notes/*.meta.json; do
77 [ -f "$META" ] || continue
78
79 NOTE_ID=$(python3 -c "import json; print(json.load(open('$META')).get('id',''))" 2>/dev/null)
80 NOTE_SLUG=$(python3 -c "import json; print(json.load(open('$META')).get('slug',''))" 2>/dev/null)
81 TITLE=$(python3 -c "import json; print(json.load(open('$META')).get('title','Untitled'))" 2>/dev/null)
82 TYPE=$(python3 -c "import json; print(json.load(open('$META')).get('type','markdown'))" 2>/dev/null)
83 TAGS=$(python3 -c "import json; print(json.dumps(json.load(open('$META')).get('tags',[])))" 2>/dev/null)
84
85 # Check if note already exists
86 if [ -n "$NOTE_SLUG" ]; then
87 EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/notes/$NOTE_SLUG" 2>/dev/null || echo "000")
88 elif [ -n "$NOTE_ID" ]; then
89 EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/notes/$NOTE_ID" 2>/dev/null || echo "000")
90 else
91 EXISTS="000"
92 fi
93
94 if [ "$EXISTS" = "200" ]; then
95 SKIPPED=$((SKIPPED + 1))
96 continue
97 fi
98
99 # Read content file
100 BASE_NAME=$(basename "$META" .meta.json)
101 if [ -f "$NB_DIR/notes/$BASE_NAME.md" ]; then
102 CONTENT_FILE="$NB_DIR/notes/$BASE_NAME.md"
103 elif [ -f "$NB_DIR/notes/$BASE_NAME.json" ]; then
104 CONTENT_FILE="$NB_DIR/notes/$BASE_NAME.json"
105 else
106 echo " WARN: No content file for $BASE_NAME"
107 continue
108 fi
109
110 # Create note via API
111 python3 -c "
112import json, sys
113meta = json.load(open('$META'))
114with open('$CONTENT_FILE') as f:
115 content = f.read()
116payload = {
117 'title': meta.get('title', 'Untitled'),
118 'content': content,
119 'tags': meta.get('tags', []),
120 'type': meta.get('type', 'markdown')
121}
122print(json.dumps(payload))
123" 2>/dev/null | curl -sf -X POST "$BASE_URL/api/notebooks/$SLUG/notes" \
124 -H "Content-Type: application/json" \
125 -d @- > /dev/null 2>&1
126
127 if [ $? -eq 0 ]; then
128 TOTAL_NOTES=$((TOTAL_NOTES + 1))
129 echo " + $TITLE"
130 else
131 echo " FAIL: $TITLE (may need auth)"
132 fi
133 done
134 fi
135
136 # Restore media
137 if [ -d "$NB_DIR/media" ]; then
138 for MEDIA_FILE in "$NB_DIR"/media/*; do
139 [ -f "$MEDIA_FILE" ] || continue
140 FILENAME=$(basename "$MEDIA_FILE")
141 HASH="${FILENAME%.*}" # Strip extension
142
143 # Check if already exists
144 EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$SLUG/media/$HASH" 2>/dev/null || echo "000")
145 if [ "$EXISTS" = "200" ]; then continue; fi
146
147 # Detect content type from extension
148 case "$FILENAME" in
149 *.png) CT="image/png" ;;
150 *.jpg) CT="image/jpeg" ;;
151 *.gif) CT="image/gif" ;;
152 *.webp) CT="image/webp" ;;
153 *.mp4) CT="video/mp4" ;;
154 *.webm) CT="video/webm" ;;
155 *.mp3) CT="audio/mpeg" ;;
156 *) CT="application/octet-stream" ;;
157 esac
158
159 curl -sf -X POST "$BASE_URL/api/notebooks/$SLUG/media" \
160 -H "Content-Type: $CT" \
161 --data-binary "@$MEDIA_FILE" > /dev/null 2>&1
162
163 if [ $? -eq 0 ]; then
164 TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
165 fi
166 done
167 fi
168done
169
170# Also restore from global media-all directory
171if [ -d "$BACKUP/media-all" ]; then
172 echo ""
173 echo "=== Restoring global media ==="
174 # Pick first notebook as target for orphan media
175 FIRST_SLUG=$(ls "$BACKUP/notebooks/" | head -1)
176 for MEDIA_FILE in "$BACKUP"/media-all/*; do
177 [ -f "$MEDIA_FILE" ] || continue
178 FILENAME=$(basename "$MEDIA_FILE")
179 HASH="${FILENAME%.*}"
180
181 EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL/api/notebooks/$FIRST_SLUG/media/$HASH" 2>/dev/null || echo "000")
182 if [ "$EXISTS" = "200" ]; then continue; fi
183
184 case "$FILENAME" in
185 *.png) CT="image/png" ;;
186 *.jpg) CT="image/jpeg" ;;
187 *.gif) CT="image/gif" ;;
188 *.webp) CT="image/webp" ;;
189 *.mp4) CT="video/mp4" ;;
190 *.webm) CT="video/webm" ;;
191 *) CT="application/octet-stream" ;;
192 esac
193
194 curl -sf -X POST "$BASE_URL/api/notebooks/$FIRST_SLUG/media" \
195 -H "Content-Type: $CT" \
196 --data-binary "@$MEDIA_FILE" > /dev/null 2>&1 && TOTAL_MEDIA=$((TOTAL_MEDIA + 1))
197 done
198fi
199
200echo ""
201echo "=== Restore Complete ==="
202echo "Notes created: $TOTAL_NOTES"
203echo "Notes skipped: $SKIPPED (already exist)"
204echo "Media uploaded: $TOTAL_MEDIA"