| … | … | @@ -70,18 +70,20 @@ hypernote serve |
| 70 | 70 | |
| 71 | 71 | ```bash |
| 72 | 72 | # Start server (localhost:4040) |
| 73 | bare hypernote-server.js serve | |
| 73 | NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve | |
| 74 | 74 | |
| 75 | 75 | # Start on LAN (accessible from other devices) |
| 76 | bare hypernote-server.js serve 4040 --network | |
| 76 | NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 4040 --network | |
| 77 | 77 | |
| 78 | 78 | # Custom port, storage, and URL prefix |
| 79 | bare hypernote-server.js serve 5555 --storage=/path/to/data --prefix=/hypernote | |
| 79 | NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 5555 --storage=/path/to/data --prefix=/hypernote | |
| 80 | 80 | |
| 81 | 81 | # Promote a user to admin (CLI) |
| 82 | 82 | bare hypernote-server.js promote <publicKey|username> |
| 83 | 83 | ``` |
| 84 | 84 | |
| 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 | ||
| 85 | 87 | On 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. |
| 86 | 88 | |
| 87 | 89 | ### Reverse Proxy |
| … | … | @@ -155,30 +157,37 @@ hn config set token <bearer-token> |
| 155 | 157 | |
| 156 | 158 | ``` |
| 157 | 159 | Browser (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 | |
| 171 | CLI (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 | |
| 180 | 178 | ``` |
| 181 | 179 | |
| 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 | ||
| 182 | 191 | ### Storage Layout |
| 183 | 192 | |
| 184 | 193 | ``` |
| … | … | @@ -231,15 +240,19 @@ Hypernote supports linking multiple instances via [Autobase](https://github.com/ |
| 231 | 240 | 1. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex |
| 232 | 241 | 2. An admin on instance B joins with that invite (`POST /api/multiwriter/join`) |
| 233 | 242 | 3. Both instances share a replicated registry (users, permissions) via Autobase |
| 234 | 4. Mirror sync copies notebooks between instances in the background | |
| 243 | 4. 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 | |
| 235 | 250 | |
| 236 | 251 | **Key endpoints:** |
| 237 | 252 | - `GET /api/multiwriter/status` -- check linking state and peers |
| 238 | 253 | - `POST /api/multiwriter/link` / `POST /api/multiwriter/join` -- instance linking |
| 239 | 254 | - `POST /api/multiwriter/sync` -- trigger manual mirror sync |
| 240 | - `POST /api/multiwriter/mirror` -- start/stop background mirror sync | |
| 241 | 255 | - `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) | |
| 243 | 256 | |
| 244 | 257 | See [docs/api.md](docs/api.md) for the complete multiwriter API reference. |
| 245 | 258 | |
| … | … | @@ -344,11 +357,63 @@ The CI pipeline (`.pear-git/ci.yaml`) installs dependencies, then pear-git runs |
| 344 | 357 | ### Manual |
| 345 | 358 | |
| 346 | 359 | ```bash |
| 347 | bare hypernote-server.js serve 4040 --network | |
| 360 | NODE_OPTIONS="--max-old-space-size=4096" bare hypernote-server.js serve 4040 --network | |
| 348 | 361 | ``` |
| 349 | 362 | |
| 350 | 363 | For production, run in tmux or as a systemd service. |
| 351 | 364 | |
| 365 | ## Backup & Restore | |
| 366 | ||
| 367 | Portable 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) | |
| 377 | node scripts/restore-direct.js ~/hypernote-backups/2026-04-06 | |
| 378 | ||
| 379 | # Extract media blobs from a raw corestore backup | |
| 380 | node scripts/extract-media.js ~/.config/hypernote/server-data ~/media-output | |
| 381 | ``` | |
| 382 | ||
| 383 | **Backup output structure:** | |
| 384 | ``` | |
| 385 | backup/ | |
| 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 | ||
| 404 | If multiwriter Autobases become bloated (from sync loops or excessive writes), compact them: | |
| 405 | ||
| 406 | ```bash | |
| 407 | # Dry run — inspect bloat ratios | |
| 408 | node scripts/compact-autobases.js ~/.config/hypernote/server-data | |
| 409 | ||
| 410 | # Apply — deletes multiwriter config, server creates fresh Autobases on restart | |
| 411 | node scripts/compact-autobases.js ~/.config/hypernote/server-data --apply | |
| 412 | ||
| 413 | # After compaction, restore notes into Hyperdrives | |
| 414 | node scripts/restore-direct.js ~/hypernote-backups/latest | |
| 415 | ``` | |
| 416 | ||
| 352 | 417 | ## Testing |
| 353 | 418 | |
| 354 | 419 | ```bash |
| … | … | @@ -369,6 +434,12 @@ hypernote/ |
| 369 | 434 | package.json |
| 370 | 435 | bin/hn # CLI entry point |
| 371 | 436 | .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 | |
| 372 | 443 | lib/ |
| 373 | 444 | auth.js # AuthManager (multi-method) |
| 374 | 445 | note-manager.js # notebook + note CRUD |
| … | … | @@ -378,23 +449,44 @@ hypernote/ |
| 378 | 449 | notebook-crypto.js # per-notebook encryption |
| 379 | 450 | link-manager.js # wiki-links + backlinks + graph |
| 380 | 451 | 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 | |
| 381 | 456 | export.js # data export (notebook + full server) |
| 382 | 457 | anproto.js # ANProto ed25519 signatures |
| 383 | 458 | storage-locator.js # storage breadcrumb system |
| 384 | 459 | errors.js # custom error classes |
| 385 | 460 | 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) | |
| 386 | 475 | media/ |
| 387 | 476 | video-processor.js # HLS transcoding via ffmpeg |
| 388 | 477 | multiwriter/ |
| 389 | manager.js # multi-writer orchestration | |
| 478 | manager.js # multi-writer orchestration (event-driven sync) | |
| 390 | 479 | instance-linker.js # invite-based instance linking |
| 391 | 480 | shared-registry.js # Autobase-backed shared user registry |
| 481 | shared-notebook.js # per-notebook Autobase | |
| 392 | 482 | registry-apply.js # registry operation application |
| 483 | notebook-apply.js # notebook operation application | |
| 393 | 484 | auth/ |
| 394 | 485 | 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 | |
| 398 | 490 | challenge-manager.js # challenge-response protocol |
| 399 | 491 | identity-registry.js # user + identity storage |
| 400 | 492 | rate-limiter.js # per-IP rate limiting |
| … | … | @@ -404,6 +496,7 @@ hypernote/ |
| 404 | 496 | routes/ |
| 405 | 497 | index.js # HTTP router |
| 406 | 498 | auth.js # identity + auth endpoints |
| 499 | auth-helpers.js # shared auth helpers (requireAuth) | |
| 407 | 500 | notebooks.js # notebook + note + media endpoints |
| 408 | 501 | sharing.js # encryption + sharing endpoints |
| 409 | 502 | links.js # links + backlinks + graph endpoints |
| … | … | @@ -418,22 +511,27 @@ hypernote/ |
| 418 | 511 | web/ |
| 419 | 512 | index.html # SPA entry point |
| 420 | 513 | js/ |
| 421 | app.js # main app + router + views | |
| 514 | app.js # main SPA app + router + views | |
| 422 | 515 | api.js # HTTP client (dual-mode: HTTP + hyper://) |
| 423 | 516 | 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) | |
| 425 | 519 | css/ |
| 426 | app.css # dark theme styles | |
| 520 | app.css # themed styles (dark, dark-blue, light) | |
| 427 | 521 | vendor/ |
| 428 | 522 | toastui-editor-all.min.js # Toast UI editor (local, no CDN) |
| 429 | 523 | toastui-editor.min.css # Toast UI styles (local, no CDN) |
| 430 | 524 | 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) | |
| 431 | 528 | test/ |
| 432 | 529 | unit/ # 17 test files (brittle) |
| 433 | 530 | helpers/ |
| 434 | 531 | fixtures.js # test helpers |
| 435 | 532 | docs/ |
| 436 | 533 | api.md # REST API reference |
| 534 | architecture.md # architecture deep dive | |
| 437 | 535 | ``` |
| 438 | 536 | |
| 439 | 537 | ## License |
| … | … | @@ -4,32 +4,39 @@ |
| 4 | 4 | |
| 5 | 5 | ``` |
| 6 | 6 | Browser (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 │ | |
| 18 | CLI (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 | └──────────────────────────────────────┘ | |
| 31 | 27 | ``` |
| 32 | 28 | |
| 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 | ||
| 33 | 40 | ## Storage Layout |
| 34 | 41 | |
| 35 | 42 | ``` |
| … | … | @@ -83,7 +90,17 @@ Hypernote supports linking multiple instances via [Autobase](https://github.com/ |
| 83 | 90 | 1. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex |
| 84 | 91 | 2. An admin on instance B joins with that invite (`POST /api/multiwriter/join`) |
| 85 | 92 | 3. Both instances share a replicated registry (users, permissions) via Autobase |
| 86 | 4. Mirror sync copies notebooks between instances in the background | |
| 93 | 4. 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:** | |
| 103 | Over 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`. | |
| 87 | 104 | |
| 88 | 105 | See [api.md](api.md) for the complete multiwriter API reference. |
| 89 | 106 |
| … | … | @@ -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 | */ | |
| 15 | export 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> | |
| 21 | window.HnApi = { basePath: ${JSON.stringify(base)}, getApiBase: function () { return ${JSON.stringify(base)}; } }; | |
| 22 | window._hnIdentityResolve = null; | |
| 23 | window.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 | } |
| … | … | @@ -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 | */ | |
| 14 | export 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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  | |
| 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 | } |
| … | … | @@ -279,7 +279,11 @@ export class MultiwriterManager { |
| 279 | 279 | } catch (err) { |
| 280 | 280 | console.error('[Mirror] Registry update failed:', err.message) |
| 281 | 281 | } |
| 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 | } | |
| 283 | 287 | |
| 284 | 288 | // 1. Register all local drives in the shared registry |
| 285 | 289 | try { |
| … | … | @@ -394,16 +398,26 @@ export class MultiwriterManager { |
| 394 | 398 | console.log('[Mirror] Created SharedNotebook for: ' + slug) |
| 395 | 399 | } |
| 396 | 400 | |
| 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 | } | |
| 405 | 414 | } |
| 406 | 415 | |
| 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 | ||
| 407 | 421 | const drive = await this._driveManager.getDrive(slug) |
| 408 | 422 | if (!drive) continue |
| 409 | 423 | |
| … | … | @@ -455,15 +469,19 @@ export class MultiwriterManager { |
| 455 | 469 | this._sharedNotebooks.set(slug, snb) |
| 456 | 470 | console.log('[Mirror] Discovered remote SharedNotebook: ' + slug) |
| 457 | 471 | |
| 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 | |
| 460 | 473 | 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 | } | |
| 467 | 485 | } |
| 468 | 486 | } catch (err) { |
| 469 | 487 | console.error('[Mirror] Failed to open remote SharedNotebook ' + slug + ':', err.message) |
| … | … | @@ -487,7 +505,7 @@ export class MultiwriterManager { |
| 487 | 505 | this._registeredNbWriters.add(regKey) |
| 488 | 506 | console.log(`[Mirror] Registered notebook writer for ${slug}: ${writerKey.substring(0, 16)}...`) |
| 489 | 507 | } 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 | |
| 491 | 509 | } |
| 492 | 510 | } |
| 493 | 511 | |
| … | … | @@ -536,7 +554,6 @@ export class MultiwriterManager { |
| 536 | 554 | try { |
| 537 | 555 | // Get all local users and sync them to shared registry |
| 538 | 556 | const users = await this._identityRegistry.listAllUsers() |
| 539 | console.log(`[Mirror] Syncing ${users.length} local users to shared registry`) | |
| 540 | 557 | for (let i = 0; i < users.length; i++) { |
| 541 | 558 | const user = users[i] |
| 542 | 559 | if (!user || !user.userId) { |
| … | … | @@ -656,38 +673,53 @@ export class MultiwriterManager { |
| 656 | 673 | } |
| 657 | 674 | |
| 658 | 675 | /** |
| 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. | |
| 660 | 682 | */ |
| 661 | 683 | startMirrorSync () { |
| 662 | if (this._syncInterval) return | |
| 684 | if (this._mirrorStarted) return | |
| 685 | this._mirrorStarted = true | |
| 686 | this._hasSynced = false | |
| 663 | 687 | |
| 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 | } | |
| 671 | 692 | |
| 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)') | |
| 678 | 699 | } |
| 679 | 700 | |
| 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) | |
| 688 | 716 | } |
| 689 | 717 | } |
| 690 | 718 | |
| 719 | stopMirrorSync () { | |
| 720 | this._mirrorStarted = false | |
| 721 | } | |
| 722 | ||
| 691 | 723 | // ── Notebook sharing ── |
| 692 | 724 | |
| 693 | 725 | async shareNotebook (slug) { |
| … | … | @@ -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 | */ | |
| 15 | export 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } | |
| 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 | } |
| … | … | @@ -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 | ||
| 12 | import { renderNav } from './nav-ui.js' | |
| 13 | import { renderIdentityScript } from './identity-ui.js' | |
| 14 | ||
| 15 | /** | |
| 16 | * Escape text for use in HTML attributes | |
| 17 | */ | |
| 18 | function escapeForAttr (text) { | |
| 19 | return (text || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>') | |
| 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 | */ | |
| 36 | export 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 | } |
| … | … | @@ -20,7 +20,7 @@ const CSP_HEADER = [ |
| 20 | 20 | "style-src 'self' 'unsafe-inline'", |
| 21 | 21 | "img-src 'self' data: https:", |
| 22 | 22 | "media-src 'self' https:", |
| 23 | "font-src 'self'", | |
| 23 | "font-src 'self' data:", | |
| 24 | 24 | "connect-src 'self'", |
| 25 | 25 | "frame-ancestors 'none'", |
| 26 | 26 | "base-uri 'self'", |
| … | … | @@ -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 | ||
| 7 | const 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 | ||
| 19 | const 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 | ||
| 26 | export 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 | } |
| … | … | @@ -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 | ||
| 8 | export 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 | } |
| … | … | @@ -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 | ||
| 8 | export 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 | } |
| … | … | @@ -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 | ||
| 8 | export 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 | } |
| … | … | @@ -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 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | ||
| 9 | function esc (s) { | |
| 10 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 26 | export 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">—</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 | } |
| … | … | @@ -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 | ||
| 9 | import { renderPage } from '../page-layout.js' | |
| 10 | ||
| 11 | function esc (s) { | |
| 12 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 23 | export 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)}">← ${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(''); | |
| 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(''); 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 | } |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | /** | |
| 2 | * Home Page — Recent notes feed | |
| 3 | * | |
| 4 | * SSR view showing recent notes across all public notebooks. | |
| 5 | */ | |
| 6 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | import { renderMarkdown } from '../markdown.js' | |
| 9 | ||
| 10 | /** | |
| 11 | * Escape text for HTML | |
| 12 | */ | |
| 13 | function esc (s) { | |
| 14 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 25 | export 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 | ||
| 98 | function 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 | } |
| … | … | @@ -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 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | import { renderMarkdown } from '../markdown.js' | |
| 9 | ||
| 10 | function esc (s) { | |
| 11 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 22 | export 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)}">← ${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 | */ | |
| 147 | function 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 | */ | |
| 172 | function 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 | } |
| … | … | @@ -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 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | ||
| 9 | function esc (s) { | |
| 10 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 21 | export 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}/">← 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 += ` · <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 | } |
| … | … | @@ -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 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | ||
| 9 | function esc (s) { | |
| 10 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 21 | export 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 | } |
| … | … | @@ -0,0 +1,49 @@ |
| 1 | /** | |
| 2 | * Search Results — Full-page search results view | |
| 3 | * | |
| 4 | * SSR view showing search results across notebooks. | |
| 5 | */ | |
| 6 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | ||
| 9 | function esc (s) { | |
| 10 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 21 | export 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 | } |
| … | … | @@ -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 | ||
| 7 | import { renderPage } from '../page-layout.js' | |
| 8 | ||
| 9 | function esc (s) { | |
| 10 | return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | */ | |
| 21 | export 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 | } |
| … | … | @@ -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 | ||
| 23 | set -euo pipefail | |
| 24 | ||
| 25 | BASE_URL="${1:-https://7rnx.net/hypernote}" | |
| 26 | DATE=$(date +%Y-%m-%d) | |
| 27 | OUTPUT="${2:-$HOME/hypernote-backups/$DATE}" | |
| 28 | BASE_URL="${BASE_URL%/}" | |
| 29 | ||
| 30 | echo "=== Hypernote Backup ===" | |
| 31 | echo "Source: $BASE_URL" | |
| 32 | echo "Output: $OUTPUT" | |
| 33 | echo "" | |
| 34 | ||
| 35 | if ! curl -sf "$BASE_URL/api/health" > /dev/null 2>&1; then | |
| 36 | echo "ERROR: Cannot reach $BASE_URL/api/health" | |
| 37 | exit 1 | |
| 38 | fi | |
| 39 | ||
| 40 | VERSION=$(curl -sf "$BASE_URL/api/health" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown") | |
| 41 | echo "Server version: $VERSION" | |
| 42 | mkdir -p "$OUTPUT" | |
| 43 | ||
| 44 | cat > "$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 | } | |
| 52 | EOF | |
| 53 | ||
| 54 | # Export users | |
| 55 | echo "[1/4] Exporting users..." | |
| 56 | curl -sf "$BASE_URL/api/admin/users" > "$OUTPUT/users.json" 2>/dev/null || echo '{"users":[]}' > "$OUTPUT/users.json" | |
| 57 | ||
| 58 | # Get notebook list | |
| 59 | echo "[2/4] Fetching notebook list..." | |
| 60 | curl -sf "$BASE_URL/api/notebooks" > "$OUTPUT/notebooks-index.json" | |
| 61 | ||
| 62 | SLUGS=$(python3 -c " | |
| 63 | import json | |
| 64 | data = json.load(open('$OUTPUT/notebooks-index.json')) | |
| 65 | nbs = data.get('notebooks', data) if isinstance(data, dict) else data | |
| 66 | for nb in nbs: | |
| 67 | print(nb.get('slug', '')) | |
| 68 | " 2>/dev/null) | |
| 69 | ||
| 70 | if [ -z "$SLUGS" ]; then | |
| 71 | echo "No notebooks found. Done." | |
| 72 | exit 0 | |
| 73 | fi | |
| 74 | ||
| 75 | NB_COUNT=$(echo "$SLUGS" | wc -l) | |
| 76 | echo "Found $NB_COUNT notebooks" | |
| 77 | ||
| 78 | # Export each notebook | |
| 79 | echo "[3/4] Exporting notebooks..." | |
| 80 | CURRENT=0 | |
| 81 | TOTAL_NOTES=0 | |
| 82 | TOTAL_MEDIA=0 | |
| 83 | ||
| 84 | for 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' | |
| 99 | import json, os, re, sys | |
| 100 | ||
| 101 | nb_dir = os.environ.get("NB_DIR", ".") | |
| 102 | notes_dir = os.environ.get("NOTES_DIR", ".") | |
| 103 | ||
| 104 | with open(os.path.join(nb_dir, "notes-raw.json")) as f: | |
| 105 | data = json.load(f) | |
| 106 | ||
| 107 | notes = data.get("notes", []) | |
| 108 | media_hashes = set() | |
| 109 | ||
| 110 | for 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 | |
| 143 | with open(os.path.join(nb_dir, "media-manifest.json"), "w") as f: | |
| 144 | json.dump(sorted(media_hashes), f, indent=2) | |
| 145 | ||
| 146 | print(f"{len(notes)} {len(media_hashes)}") | |
| 147 | PYEOF | |
| 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" | |
| 158 | done | |
| 159 | ||
| 160 | # Download media files | |
| 161 | echo "[4/4] Downloading media files..." | |
| 162 | for 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 | |
| 198 | done | |
| 199 | ||
| 200 | # Summary | |
| 201 | echo "" | |
| 202 | echo "=== Backup Complete ===" | |
| 203 | echo "Location: $OUTPUT" | |
| 204 | echo "Notebooks: $NB_COUNT" | |
| 205 | echo "Notes: $TOTAL_NOTES" | |
| 206 | echo "Media: $TOTAL_MEDIA files" | |
| 207 | echo "Size: $(du -sh "$OUTPUT" | cut -f1)" |
| … | … | @@ -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 | ||
| 21 | import Corestore from 'corestore' | |
| 22 | import Hyperbee from 'hyperbee' | |
| 23 | import Autobase from 'autobase' | |
| 24 | ||
| 25 | const args = process.argv.slice(2) | |
| 26 | const storagePath = args[0] || process.env.HOME + '/.config/hypernote/server-data' | |
| 27 | const apply = args.includes('--apply') | |
| 28 | ||
| 29 | console.log('=== Autobase Compaction ===') | |
| 30 | console.log('Storage:', storagePath) | |
| 31 | console.log('Mode:', apply ? 'APPLY (will modify data)' : 'DRY RUN (read-only)') | |
| 32 | console.log('') | |
| 33 | ||
| 34 | const store = new Corestore(storagePath) | |
| 35 | await store.ready() | |
| 36 | ||
| 37 | // Open the main registry to find multiwriter config | |
| 38 | const registryCore = store.get({ name: 'registry' }) | |
| 39 | const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }) | |
| 40 | await registry.ready() | |
| 41 | ||
| 42 | const mwConfig = await registry.get('multiwriter-config') | |
| 43 | if (!mwConfig || !mwConfig.value) { | |
| 44 | console.log('No multiwriter config found — nothing to compact.') | |
| 45 | await store.close() | |
| 46 | process.exit(0) | |
| 47 | } | |
| 48 | ||
| 49 | const bootstrapKey = mwConfig.value.registryBootstrapKey | |
| 50 | console.log('Multiwriter config:', JSON.stringify(mwConfig.value, null, 2)) | |
| 51 | console.log('') | |
| 52 | ||
| 53 | // Open SharedRegistry to inspect its state | |
| 54 | const regStore = store.namespace('shared-registry') | |
| 55 | const 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 | }) | |
| 69 | await regBase.ready() | |
| 70 | await regBase.update() | |
| 71 | ||
| 72 | // Count view entries | |
| 73 | let viewEntries = 0 | |
| 74 | let notebooks = [] | |
| 75 | for 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 | ||
| 83 | const localWriterLen = regBase.local ? regBase.local.length : 0 | |
| 84 | console.log('SharedRegistry:') | |
| 85 | console.log(' View entries (unique):', viewEntries) | |
| 86 | console.log(' Local writer blocks:', localWriterLen) | |
| 87 | console.log(' Bloat ratio:', localWriterLen > 0 ? (localWriterLen / Math.max(viewEntries, 1)).toFixed(1) + 'x' : 'N/A') | |
| 88 | console.log(' Notebooks:', notebooks.map(n => n.slug).join(', ')) | |
| 89 | console.log('') | |
| 90 | ||
| 91 | // Inspect each SharedNotebook | |
| 92 | for (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 | ||
| 131 | console.log('') | |
| 132 | ||
| 133 | if (!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 | |
| 147 | console.log('=== Applying compaction ===') | |
| 148 | console.log('Deleting multiwriter-config from registry...') | |
| 149 | await registry.del('multiwriter-config') | |
| 150 | console.log('Done. Multiwriter config removed.') | |
| 151 | console.log('') | |
| 152 | console.log('On next server start:') | |
| 153 | console.log(' - Fresh Autobases will be created') | |
| 154 | console.log(' - Mirror sync will re-populate from local Hyperdrives') | |
| 155 | console.log(' - Memory usage will drop significantly') | |
| 156 | console.log('') | |
| 157 | console.log('Old Autobase cores are still on disk but unused.') | |
| 158 | console.log('Run hyper-inspector to verify storage savings after restart.') | |
| 159 | ||
| 160 | await regBase.close() | |
| 161 | await store.close() |
| … | … | @@ -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 | ||
| 13 | import RocksDB from 'rocksdb-native' | |
| 14 | import Corestore from 'corestore' | |
| 15 | import Hyperbee from 'hyperbee' | |
| 16 | import Hyperblobs from 'hyperblobs' | |
| 17 | import fs from 'fs' | |
| 18 | import path from 'path' | |
| 19 | ||
| 20 | const args = process.argv.slice(2) | |
| 21 | if (args.length < 2) { | |
| 22 | console.log('Usage: node scripts/extract-media.js <storage-path> <output-dir> [hash1 hash2 ...]') | |
| 23 | process.exit(1) | |
| 24 | } | |
| 25 | ||
| 26 | const storagePath = args[0] | |
| 27 | const outputDir = args[1] | |
| 28 | const requestedHashes = args.slice(2) | |
| 29 | ||
| 30 | // Resolve the actual corestore path | |
| 31 | let corestorePath = storagePath | |
| 32 | if (fs.existsSync(path.join(storagePath, 'corestore'))) { | |
| 33 | corestorePath = path.join(storagePath, 'corestore') | |
| 34 | } | |
| 35 | ||
| 36 | if (!fs.existsSync(corestorePath)) { | |
| 37 | console.error('Storage path not found:', corestorePath) | |
| 38 | process.exit(1) | |
| 39 | } | |
| 40 | ||
| 41 | fs.mkdirSync(outputDir, { recursive: true }) | |
| 42 | ||
| 43 | console.log('Opening corestore:', storagePath) | |
| 44 | console.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 | |
| 48 | const dbPath = path.join(corestorePath, 'db') | |
| 49 | const lockPath = path.join(dbPath, 'LOCK') | |
| 50 | const deviceFiles = [] | |
| 51 | ||
| 52 | // Find and temporarily rename LOCK files so Corestore can open | |
| 53 | for (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 | ||
| 62 | let store | |
| 63 | try { | |
| 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 | ||
| 75 | const 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 | ||
| 87 | try { | |
| 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 | ||
| 165 | function 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 | } |
| … | … | @@ -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 | ||
| 11 | import Corestore from 'corestore' | |
| 12 | import Hyperbee from 'hyperbee' | |
| 13 | import Hyperdrive from 'hyperdrive' | |
| 14 | import fs from 'fs' | |
| 15 | import path from 'path' | |
| 16 | import crypto from 'crypto' | |
| 17 | ||
| 18 | const args = process.argv.slice(2) | |
| 19 | const backupDir = args[0] | |
| 20 | const storagePath = args[1] || process.env.HOME + '/.config/hypernote/server-data' | |
| 21 | ||
| 22 | if (!backupDir || !fs.existsSync(backupDir)) { | |
| 23 | console.log('Usage: node scripts/restore-direct.js <backup-dir> [storage-path]') | |
| 24 | process.exit(1) | |
| 25 | } | |
| 26 | ||
| 27 | console.log('=== Direct Restore ===') | |
| 28 | console.log('Backup:', backupDir) | |
| 29 | console.log('Storage:', storagePath) | |
| 30 | console.log('') | |
| 31 | ||
| 32 | const store = new Corestore(storagePath) | |
| 33 | await store.ready() | |
| 34 | ||
| 35 | const registryCore = store.get({ name: 'registry' }) | |
| 36 | const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }) | |
| 37 | await registry.ready() | |
| 38 | ||
| 39 | // Build drive index from registry | |
| 40 | const driveIndex = new Map() | |
| 41 | for 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 | ||
| 46 | let totalRestored = 0 | |
| 47 | let totalSkipped = 0 | |
| 48 | let totalCreated = 0 | |
| 49 | ||
| 50 | const notebooksDir = path.join(backupDir, 'notebooks') | |
| 51 | if (!fs.existsSync(notebooksDir)) { | |
| 52 | console.log('No notebooks directory found') | |
| 53 | await store.close() | |
| 54 | process.exit(1) | |
| 55 | } | |
| 56 | ||
| 57 | for (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 | |
| 161 | const mediaAllDir = path.join(backupDir, 'media-all') | |
| 162 | if (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 | ||
| 206 | console.log(`\n=== Restore Complete ===`) | |
| 207 | console.log(`Notes restored: ${totalRestored}`) | |
| 208 | console.log(`Notes skipped: ${totalSkipped} (already exist)`) | |
| 209 | console.log(`Drives created: ${totalCreated}`) | |
| 210 | ||
| 211 | await store.close() |
| … | … | @@ -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 | ||
| 11 | set -euo pipefail | |
| 12 | ||
| 13 | BACKUP="${1:-$HOME/hypernote-backups/2026-04-06-pre-compaction}" | |
| 14 | BASE_URL="${2:-http://localhost:4040/hypernote}" | |
| 15 | BASE_URL="${BASE_URL%/}" | |
| 16 | ||
| 17 | echo "=== Hypernote Restore ===" | |
| 18 | echo "Backup: $BACKUP" | |
| 19 | echo "Target: $BASE_URL" | |
| 20 | echo "" | |
| 21 | ||
| 22 | if [ ! -d "$BACKUP/notebooks" ]; then | |
| 23 | echo "ERROR: No notebooks directory in $BACKUP" | |
| 24 | exit 1 | |
| 25 | fi | |
| 26 | ||
| 27 | if ! curl -sf "$BASE_URL/api/health" > /dev/null 2>&1; then | |
| 28 | echo "ERROR: Cannot reach $BASE_URL/api/health" | |
| 29 | exit 1 | |
| 30 | fi | |
| 31 | ||
| 32 | echo "WARNING: This will create notebooks and notes that don't already exist." | |
| 33 | echo "Existing notes with the same ID will be SKIPPED (not overwritten)." | |
| 34 | echo "" | |
| 35 | read -p "Continue? [y/N] " confirm | |
| 36 | if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then | |
| 37 | echo "Aborted." | |
| 38 | exit 0 | |
| 39 | fi | |
| 40 | ||
| 41 | TOTAL_NOTES=0 | |
| 42 | TOTAL_MEDIA=0 | |
| 43 | SKIPPED=0 | |
| 44 | ||
| 45 | for 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 " | |
| 112 | import json, sys | |
| 113 | meta = json.load(open('$META')) | |
| 114 | with open('$CONTENT_FILE') as f: | |
| 115 | content = f.read() | |
| 116 | payload = { | |
| 117 | 'title': meta.get('title', 'Untitled'), | |
| 118 | 'content': content, | |
| 119 | 'tags': meta.get('tags', []), | |
| 120 | 'type': meta.get('type', 'markdown') | |
| 121 | } | |
| 122 | print(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 | |
| 168 | done | |
| 169 | ||
| 170 | # Also restore from global media-all directory | |
| 171 | if [ -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 | |
| 198 | fi | |
| 199 | ||
| 200 | echo "" | |
| 201 | echo "=== Restore Complete ===" | |
| 202 | echo "Notes created: $TOTAL_NOTES" | |
| 203 | echo "Notes skipped: $SKIPPED (already exist)" | |
| 204 | echo "Media uploaded: $TOTAL_MEDIA" |