OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 1192ced

Add note slugs and README table of contents
Notes now have human-readable slugs (e.g. meeting-notes-march) auto-generated from titles. URLs use slugs instead of hex IDs with backward compatibility. Each notebook gets a README note with auto-generated TOC between markers. TOC regenerates on note create/update/delete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1192ced828e34deb212ef33e0d71c0725780c205
by plankatron <fleeky@gmail.com> · 2026-03-17T15:15:38.000Z

Changed files (9) +834 -70

README.md +62 -2
……@@ -207,6 +207,26 @@ Each notebook is a Hyperdrive. To share:
207207
208208Only announced notebooks are discoverable. The registry (users, tokens, permissions) is never replicated.
209209
210### Multi-Writer / Device Linking
211
212Hypernote supports linking multiple instances via [Autobase](https://github.com/holepunchto/autobase) for multi-device and multi-user collaboration.
213
214**How it works:**
2151. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex
2162. An admin on instance B joins with that invite (`POST /api/multiwriter/join`)
2173. Both instances share a replicated registry (users, permissions) via Autobase
2184. Mirror sync copies notebooks between instances in the background
219
220**Key endpoints:**
221- `GET /api/multiwriter/status` -- check linking state and peers
222- `POST /api/multiwriter/link` / `POST /api/multiwriter/join` -- instance linking
223- `POST /api/multiwriter/sync` -- trigger manual mirror sync
224- `POST /api/multiwriter/mirror` -- start/stop background mirror sync
225- `POST /api/notebooks/:nb/share` -- toggle Autobase sharing on a notebook
226- Shared notebooks get their own CRDT-based note endpoints (`/shared-notes`, Yjs updates)
227
228See [docs/api.md](docs/api.md) for the complete multiwriter API reference.
229
210230### Site Publisher
211231
212232The server automatically publishes a Hyperdrive containing:
……@@ -231,27 +251,61 @@ See [docs/api.md](docs/api.md) for the complete REST API reference.
231251| GET | `/api/site` | Site drive key + hyper:// URL |
232252| POST | `/api/site/publish` | Force republish site (admin) |
233253| GET | `/api/recent` | Recent notes feed |
234| GET | `/api/notebooks` | List all notebooks |
254| GET | `/api/notebooks` | List notebooks (?mine=true, ?owner=) |
235255| POST | `/api/notebooks` | Create notebook |
236256| GET | `/api/notebooks/:nb` | Get notebook metadata |
237257| PATCH | `/api/notebooks/:nb` | Update notebook |
238258| DELETE | `/api/notebooks/:nb` | Delete notebook |
239| GET | `/api/notebooks/:nb/notes` | List notes |
259| POST | `/api/notebooks/:nb/rename` | Rename notebook slug |
260| GET | `/api/notebooks/:nb/notes` | List notes (?limit, ?offset) |
240261| POST | `/api/notebooks/:nb/notes` | Create note |
241262| GET | `/api/notebooks/:nb/notes/:id` | Get note |
242263| PATCH | `/api/notebooks/:nb/notes/:id` | Update note |
243264| DELETE | `/api/notebooks/:nb/notes/:id` | Delete note |
265| POST | `/api/notebooks/:nb/notes/:id/move` | Move note to another notebook |
244266| POST | `/api/notebooks/:nb/media` | Upload media |
267| GET | `/api/notebooks/:nb/media/:file` | Serve media file (no auth) |
245268| GET | `/api/search?q=term` | Full-text search |
246269| POST | `/api/notebooks/:nb/encrypt` | Encrypt notebook |
270| POST | `/api/notebooks/:nb/decrypt` | Decrypt notebook |
271| POST | `/api/notebooks/:nb/unlock` | Unlock encrypted notebook |
272| POST | `/api/notebooks/:nb/share` | Share notebook (token or multiwriter) |
273| GET | `/api/notebooks/:nb/shares` | List share tokens (admin) |
274| DELETE | `/api/notebooks/:nb/shares/:hash` | Revoke share token (admin) |
275| GET | `/api/notebooks/:nb/notes/:id/links` | Get outgoing wiki-links |
276| GET | `/api/notebooks/:nb/notes/:id/backlinks` | Get backlinks |
277| GET | `/api/graph` | Knowledge graph (?notebook=) |
278| POST | `/api/notebooks/:nb/merge` | Merge two notes |
279| POST | `/api/notebooks/:nb/notes/:id/split` | Split note at headings |
247280| POST | `/api/notebooks/:nb/announce` | Announce on Hyperswarm |
248281| POST | `/api/notebooks/:nb/unannounce` | Stop announcing |
282| GET | `/api/notebooks/:nb/drive-info` | Hyperdrive info |
249283| GET | `/api/sync/status` | P2P sync status |
250284| POST | `/api/sync/add` | Add remote notebook |
285| DELETE | `/api/sync/remote/:nb` | Remove remote notebook |
251286| POST | `/api/auth/challenge` | Get signing challenge |
287| GET | `/api/user/profile` | Get user profile |
288| PATCH | `/api/user/profile` | Update display name/theme |
289| POST | `/api/user/username` | Set username |
290| GET | `/api/user/username/:u/available` | Check username availability |
291| GET | `/api/users` | Public user directory |
252292| GET | `/api/admin/users` | List all users (admin) |
293| DELETE | `/api/admin/users/:id` | Delete user (admin) |
253294| GET | `/api/admin/stats` | Server stats (admin) |
254295| POST | `/api/admin/promote` | Promote user (admin) |
296| POST | `/api/admin/demote` | Demote user (admin) |
297| POST | `/api/admin/notebooks/:nb/transfer` | Transfer ownership (admin) |
298| GET | `/api/multiwriter/status` | Multi-writer status (admin) |
299| POST | `/api/multiwriter/link` | Create link invite (admin) |
300| POST | `/api/multiwriter/join` | Join with invite (admin) |
301| POST | `/api/multiwriter/add-writer` | Add writer key (admin) |
302| POST | `/api/multiwriter/sync` | Trigger mirror sync (admin) |
303| POST | `/api/multiwriter/mirror` | Start/stop mirror (admin) |
304| GET | `/api/notebooks/:nb/shared-notes` | List shared notes |
305| POST | `/api/notebooks/:nb/shared-notes` | Create shared note |
306| POST | `/api/notebooks/:nb/shared-notes/:id/yjs` | Apply Yjs update |
307| GET | `/api/notebooks/:nb/shared-notes/:id/content` | Get shared note content |
308| GET | `/api/debug/identity-chain` | Identity chain (localhost) |
255309
256310## Deployment
257311
……@@ -306,6 +360,11 @@ hypernote/
306360 storage-locator.js # storage breadcrumb system
307361 errors.js # custom error classes
308362 response.js # HTTP response helpers
363 multiwriter/
364 manager.js # multi-writer orchestration
365 instance-linker.js # invite-based instance linking
366 shared-registry.js # Autobase-backed shared user registry
367 registry-apply.js # registry operation application
309368 auth/
310369 base-provider.js # auth provider interface
311370 bearer-token.js # bearer token auth
……@@ -324,6 +383,7 @@ hypernote/
324383 sharing.js # encryption + sharing endpoints
325384 links.js # links + backlinks + graph endpoints
326385 sync.js # P2P sync endpoints
386 multiwriter.js # instance linking + shared notebooks
327387 web.js # static files + vendor + public URLs
328388 cli/
329389 client.js # HTTP client for CLI
docs/api.md +266 -4
……@@ -101,7 +101,7 @@ Force republish the site drive. Requires `admin` permission.
101101Get recent notes across all notebooks. No auth required.
102102
103103**Query parameters:**
104- `limit` -- max number of results (optional, default 20)
104- `limit` -- max number of results (optional, default 20, max 50)
105105
106106```json
107107{
……@@ -126,6 +126,10 @@ Get recent notes across all notebooks. No auth required.
126126
127127List all notebooks.
128128
129**Query parameters:**
130- `mine=true` -- filter to notebooks owned by the authenticated user (requires auth)
131- `owner=username` -- filter to public notebooks owned by a specific user (by username)
132
129133```json
130134{
131135 "notebooks": [
……@@ -218,6 +222,10 @@ Rename a notebook's slug. Requires `write` permission.
218222
219223List all notes in a notebook.
220224
225**Query parameters:**
226- `limit` -- max number of results (optional)
227- `offset` -- pagination offset (optional)
228
221229```json
222230{
223231 "notes": [
……@@ -413,16 +421,53 @@ Temporarily unlock an encrypted notebook for reading (key cached in memory).
413421
414422### POST /api/notebooks/:notebook/share
415423
416Share a notebook with another user. Requires `admin` permission.
424Create a share token for a notebook. Requires `admin` permission.
417425
418426**Body:**
419427```json
420428{
421 "userId": "target-user-id",
422 "permissions": ["read", "write"]
429 "permissions": ["read", "write"],
430 "label": "For Bob",
431 "expiresAt": null
423432}
424433```
425434
435**Response (201):**
436```json
437{
438 "token": "bearer-token-string",
439 "tokenHash": "abc123...",
440 "notebook": "work-notes",
441 "permissions": ["read", "write"],
442 "label": "For Bob"
443}
444```
445
446### GET /api/notebooks/:notebook/shares
447
448List all active share tokens for a notebook. Requires `admin` permission.
449
450```json
451[
452 {
453 "notebook": "work-notes",
454 "tokenHash": "abc123...",
455 "permissions": ["read"],
456 "sharedBy": "alice",
457 "sharedAt": 1708000000000,
458 "label": "Public link"
459 }
460]
461```
462
463### DELETE /api/notebooks/:notebook/shares/:tokenHash
464
465Revoke a share token. Requires `admin` permission.
466
467```json
468{ "revoked": true, "tokenHash": "abc123...", "notebook": "work-notes" }
469```
470
426471---
427472
428473## Links & Graph
……@@ -655,6 +700,23 @@ Set a username (unique, used in public URLs).
655700
656701Returns `409 Conflict` if the username is taken.
657702
703### GET /api/users
704
705Public user directory. Lists all users who have set a username. **No auth required.**
706
707```json
708{
709 "users": [
710 {
711 "userId": "a1b2c3d4...",
712 "username": "alice",
713 "name": "Alice",
714 "createdAt": 1708000000000
715 }
716 ]
717}
718```
719
658720### GET /api/user/username/:username/available
659721
660722Check if a username is available. **No auth required.**
……@@ -787,6 +849,206 @@ Transfer notebook ownership.
787849
788850---
789851
852## Multiwriter & Instance Linking
853
854Multi-writer endpoints enable linking multiple Hypernote instances via Autobase so they share a replicated registry and can mirror notebooks between devices.
855
856All multiwriter endpoints require `admin` permission unless noted otherwise.
857
858### GET /api/multiwriter/status
859
860Get current multi-writer state (linked status, registry key, peers, drives).
861
862```json
863{
864 "linked": true,
865 "registryKey": "a1b2c3d4...",
866 "peers": 2,
867 "drives": ["work-notes", "personal"]
868}
869```
870
871### POST /api/multiwriter/link
872
873Create a link invite for another instance to join. Returns the invite hex immediately; the server waits for the peer in the background.
874
875```json
876{ "invite": "a1b2c3d4e5f6..." }
877```
878
879After a peer connects, the server persists the multiwriter config and starts mirror sync automatically.
880
881### POST /api/multiwriter/join
882
883Join an existing multi-writer group using an invite hex from another instance.
884
885**Body:**
886```json
887{ "invite": "a1b2c3d4e5f6..." }
888```
889
890**Response:**
891```json
892{
893 "success": true,
894 "bootstrapKey": "d4e5f6...",
895 "localWriterKey": "a1b2c3..."
896}
897```
898
899### POST /api/multiwriter/add-writer
900
901Add a remote writer key to the shared registry. Can be authenticated via a one-time callback token (inter-instance linking) or admin auth.
902
903**Body:**
904```json
905{ "writerKey": "a1b2c3d4...", "callbackToken": "optional-token" }
906```
907
908**Response:**
909```json
910{ "success": true, "writerKey": "a1b2c3d4..." }
911```
912
913### POST /api/multiwriter/sync
914
915Trigger a manual mirror sync between linked instances. Returns the updated multiwriter status.
916
917```json
918{
919 "synced": true,
920 "linked": true,
921 "registryKey": "a1b2c3d4...",
922 "peers": 2,
923 "drives": ["work-notes"]
924}
925```
926
927### POST /api/multiwriter/mirror
928
929Start or stop the background mirror sync process.
930
931**Body:**
932```json
933{ "enabled": true }
934```
935
936**Response (start):**
937```json
938{ "mirror": true, "message": "Mirror sync started" }
939```
940
941**Response (stop):**
942```json
943{ "mirror": false, "message": "Mirror sync stopped" }
944```
945
946### POST /api/notebooks/:notebook/share (multiwriter)
947
948Toggle multi-writer sharing on a notebook via Autobase. Distinct from the token-based sharing endpoint in the Sharing section.
949
950**Body:**
951```json
952{ "shared": true }
953```
954
955**Response (enable):**
956```json
957{ "shared": true, "autobaseKey": "a1b2c3d4..." }
958```
959
960**Response (disable):**
961```json
962{ "shared": false }
963```
964
965### GET /api/notebooks/:notebook/shared-notes
966
967List notes from a shared Autobase notebook. No auth required (open read).
968
969```json
970{
971 "notes": [
972 { "id": "abc123", "title": "Collaborative Note", "author": "a1b2c3d4...", "tags": ["shared"] }
973 ]
974}
975```
976
977Returns `404` if the notebook is not shared.
978
979### POST /api/notebooks/:notebook/shared-notes
980
981Create a note in a shared Autobase notebook. Requires `write` permission.
982
983**Body:**
984```json
985{ "title": "New Shared Note", "tags": ["shared"] }
986```
987
988**Response (201):**
989```json
990{ "id": "abc123", "title": "New Shared Note", "author": "a1b2c3d4...", "tags": ["shared"] }
991```
992
993### POST /api/notebooks/:notebook/shared-notes/:noteId/yjs
994
995Apply a Yjs CRDT update to a shared note for real-time collaborative editing. Requires `write` permission.
996
997**Body:**
998```json
999{ "update": "base64-encoded-yjs-update" }
1000```
1001
1002**Response:**
1003```json
1004{ "ok": true }
1005```
1006
1007### GET /api/notebooks/:notebook/shared-notes/:noteId/content
1008
1009Get the reconstructed content of a shared note (applies all Yjs updates). No auth required (open read).
1010
1011```json
1012{
1013 "id": "abc123",
1014 "title": "Collaborative Note",
1015 "content": "# Reconstructed content from Yjs document...",
1016 "author": "a1b2c3d4..."
1017}
1018```
1019
1020---
1021
1022## Debug Endpoints
1023
1024Diagnostic endpoints for troubleshooting. Restricted to localhost.
1025
1026### GET /api/debug/identity-chain
1027
1028Dump the full identity resolution chain for a public key. **Localhost only** -- returns `403` from remote IPs.
1029
1030**Query parameters:**
1031- `pubkey` -- base64 public key to trace
1032
1033```
1034GET /api/debug/identity-chain?pubkey=BASE64_PUBLIC_KEY
1035```
1036
1037```json
1038{
1039 "pubkey": "...",
1040 "pubkeyEntry": { "identityId": "...", "userId": "..." },
1041 "identityEntry": { "userId": "...", "method": "web-identity", "publicKey": "..." },
1042 "userEntry": { "userId": "...", "username": "alice", "globalPermissions": ["read", "write", "admin"] },
1043 "sharedRegistryUser": null,
1044 "allUsers": [
1045 { "userId": "...", "username": "alice", "admin": true, "identities": ["..."], "mergedInto": null }
1046 ]
1047}
1048```
1049
1050---
1051
7901052## CORS
7911053
7921054The server includes CORS headers for all origins, enabling access from `hyper://` pages (Agregore browser) and other cross-origin clients. Preflight `OPTIONS` requests are handled automatically.
lib/multiwriter/notebook-apply.js +7 -0
……@@ -35,6 +35,7 @@ export async function notebookApply (batch, view, base) {
3535 await b.put(`note:${op.noteId}`, {
3636 noteId: op.noteId,
3737 title: op.title || 'Untitled',
38 slug: op.slug || '',
3839 author: op.author,
3940 tags: op.tags || [],
4041 createdAt: op.timestamp,
……@@ -49,11 +50,17 @@ export async function notebookApply (batch, view, base) {
4950 await b.put(`note:${op.noteId}`, {
5051 ...note.value,
5152 title: op.title,
53 slug: op.slug || note.value.slug || '',
5254 updatedAt: op.timestamp
5355 })
5456 break
5557 }
5658
59 case 'set-readme': {
60 await b.put('notebook:readmeId', { readmeId: op.readmeId, updatedAt: op.timestamp })
61 break
62 }
63
5764 case 'delete-note': {
5865 await b.del(`note:${op.noteId}`)
5966 break
lib/multiwriter/shared-notebook.js +19 -2
……@@ -82,12 +82,13 @@ export class SharedNotebook {
8282
8383 // ── Notes ──
8484
85 async createNote ({ title, author, tags }) {
85 async createNote ({ title, slug, author, tags }) {
8686 const noteId = generateNoteId()
8787 await this._base.append(encodeOp({
8888 type: 'create-note',
8989 noteId,
9090 title: title || 'Untitled',
91 slug: slug || '',
9192 author: author || 'unknown',
9293 tags: tags || [],
9394 timestamp: Date.now()
……@@ -145,17 +146,33 @@ export class SharedNotebook {
145146 return notes
146147 }
147148
148 async updateNoteTitle (noteId, title, author) {
149 async updateNoteTitle (noteId, title, author, slug) {
149150 await this._base.append(encodeOp({
150151 type: 'update-note-title',
151152 noteId,
152153 title,
154 slug: slug || '',
153155 author: author || 'unknown',
154156 timestamp: Date.now()
155157 }))
156158 await this._base.update()
157159 }
158160
161 async setReadmeId (readmeId) {
162 await this._base.append(encodeOp({
163 type: 'set-readme',
164 readmeId,
165 timestamp: Date.now()
166 }))
167 await this._base.update()
168 }
169
170 async getReadmeId () {
171 await this._base.update()
172 const entry = await this._base.view.get('notebook:readmeId')
173 return entry ? entry.value.readmeId : null
174 }
175
159176 async deleteNote (noteId, author) {
160177 await this._base.append(encodeOp({
161178 type: 'delete-note',
lib/note-manager.js +259 -10
……@@ -14,6 +14,23 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
1414const MAX_SLUG = 64
1515const MAX_TITLE = 256
1616
17const TOC_START = '<!-- TOC START -->'
18const TOC_END = '<!-- TOC END -->'
19
20/**
21 * Generate a URL-safe slug from a title string.
22 * Lowercase, non-alphanumeric → hyphens, collapse multiples, trim.
23 */
24export function slugify (title) {
25 if (!title || typeof title !== 'string') return ''
26 return title
27 .toLowerCase()
28 .replace(/[^a-z0-9]+/g, '-')
29 .replace(/-+/g, '-')
30 .replace(/^-|-$/g, '')
31 .substring(0, MAX_SLUG)
32}
33
1734export class NoteManager {
1835 constructor (driveManager, { crypto, linkManager, multiwriter } = {}) {
1936 this.driveManager = driveManager
……@@ -82,6 +99,142 @@ export class NoteManager {
8299 this._keyCache.delete(slug)
83100 }
84101
102 // ==================== SLUG HELPERS ====================
103
104 /**
105 * Ensure a note slug is unique within a notebook.
106 * If collision, appends -2, -3, etc.
107 */
108 async _ensureUniqueSlug (notebook, slug, excludeId) {
109 if (!slug) slug = 'untitled'
110 const notes = await this.listNotes(notebook)
111 const existing = new Set()
112 for (const n of notes) {
113 if (n.id !== excludeId && n.slug) existing.add(n.slug)
114 }
115 if (!existing.has(slug)) return slug
116 let i = 2
117 while (existing.has(slug + '-' + i)) i++
118 return slug + '-' + i
119 }
120
121 /**
122 * Look up a note by slug within a notebook.
123 * Returns the note object or null.
124 */
125 async getNoteBySlug (notebook, noteSlug) {
126 const notes = await this.listNotes(notebook)
127 return notes.find(n => n.slug === noteSlug) || null
128 }
129
130 /**
131 * Resolve a note identifier — could be a slug or a hex ID.
132 * Tries slug first, falls back to hex ID lookup.
133 */
134 async resolveNote (notebook, identifier) {
135 // Try slug lookup first
136 const bySlug = await this.getNoteBySlug(notebook, identifier)
137 if (bySlug) return bySlug
138
139 // Fall back to hex ID if it looks like one
140 if (/^[a-f0-9]{16}$/.test(identifier)) {
141 return this.getNote(notebook, identifier)
142 }
143
144 return null
145 }
146
147 /**
148 * Ensure a note has a slug field. If missing, generate from title and persist.
149 */
150 _ensureSlug (note, notebook) {
151 if (note.slug) return note
152 const base = note.title ? slugify(note.title) : 'untitled-' + (note.id || '').substring(0, 8)
153 note.slug = base || 'untitled'
154 return note
155 }
156
157 // ==================== README / TOC ====================
158
159 /**
160 * Get the README note for a notebook, or null if none exists.
161 */
162 async getReadme (notebook) {
163 const meta = await this.getNotebook(notebook)
164 if (!meta || !meta.readmeId) return null
165 return this.getNote(notebook, meta.readmeId)
166 }
167
168 /**
169 * Create a README note for a notebook. Sets readmeId in metadata.
170 */
171 async createReadme (notebook) {
172 const meta = await this.getNotebook(notebook)
173 if (!meta) throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
174
175 const title = 'README'
176 const content = `# ${meta.name || notebook}\n\n${TOC_START}\n${TOC_END}\n`
177
178 const note = await this.createNote(notebook, { title, content, tags: [] })
179
180 // Save readmeId to metadata
181 meta.readmeId = note.id
182 meta.updatedAt = Date.now()
183 const toStore = { ...meta }
184 delete toStore.isLocal
185 delete toStore.driveKey
186 delete toStore.shared
187 delete toStore.autobaseKey
188 const drive = await this._getDrive(notebook)
189 await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
190
191 return note
192 }
193
194 /**
195 * Lazy migration: ensure a README exists for a notebook.
196 */
197 async ensureReadme (notebook) {
198 const existing = await this.getReadme(notebook)
199 if (existing) return existing
200
201 // Don't create README for remote notebooks
202 if (this.driveManager.isRemote(notebook)) return null
203
204 return this.createReadme(notebook)
205 }
206
207 /**
208 * Regenerate the TOC between markers in the README note.
209 * Lists all notes (excluding README) alphabetically with slug links.
210 */
211 async regenerateToc (notebook) {
212 const meta = await this.getNotebook(notebook)
213 if (!meta || !meta.readmeId) return
214
215 const readme = await this.getNote(notebook, meta.readmeId)
216 if (!readme) return
217
218 const content = readme.content || ''
219 const startIdx = content.indexOf(TOC_START)
220 const endIdx = content.indexOf(TOC_END)
221 if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return // Markers removed — no-op
222
223 // Build TOC entries from all notes except README
224 const notes = await this.listNotes(notebook)
225 const entries = notes
226 .filter(n => n.id !== meta.readmeId)
227 .sort((a, b) => (a.title || '').localeCompare(b.title || ''))
228 .map(n => `- [${n.title || 'Untitled'}](${n.slug || n.id})`)
229
230 const tocContent = entries.length > 0 ? '\n' + entries.join('\n') + '\n' : '\n'
231 const newContent = content.substring(0, startIdx + TOC_START.length) + tocContent + content.substring(endIdx)
232
233 if (newContent !== content) {
234 await this.updateNote(notebook, meta.readmeId, { content: newContent })
235 }
236 }
237
85238 /**
86239 * Encrypt title + content of a note if the notebook is encrypted.
87240 */
……@@ -177,6 +330,13 @@ export class NoteManager {
177330 }
178331 }
179332
333 // Create README note for the new notebook
334 try {
335 await this.createReadme(slug)
336 } catch (err) {
337 console.error('[NoteManager] Failed to create README for ' + slug + ':', err.message)
338 }
339
180340 return metadata
181341 }
182342
……@@ -354,11 +514,16 @@ export class NoteManager {
354514 async createNote (notebook, { title, content, tags } = {}) {
355515 this.validateTitle(title)
356516
517 // Generate slug from title
518 const baseSlug = slugify(title) || 'untitled'
519 const noteSlug = await this._ensureUniqueSlug(notebook, baseSlug, null)
520
357521 // SharedNotebook path (multi-writer)
358522 const snb = this._getSharedNotebook(notebook)
359523 if (snb) {
360524 const { noteId } = await snb.createNote({
361525 title,
526 slug: noteSlug,
362527 author: 'user',
363528 tags: Array.isArray(tags) ? tags : []
364529 })
……@@ -368,14 +533,18 @@ export class NoteManager {
368533 doc.getText('content').insert(0, content)
369534 await snb.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), 'user')
370535 }
371 return {
536 const result = {
372537 id: noteId,
538 slug: noteSlug,
373539 title,
374540 content: content || '',
375541 tags: Array.isArray(tags) ? tags : [],
376542 createdAt: Date.now(),
377543 updatedAt: Date.now()
378544 }
545 // Regenerate TOC in background
546 this.regenerateToc(notebook).catch(() => {})
547 return result
379548 }
380549
381550 // Existing Hyperdrive path
……@@ -391,6 +560,7 @@ export class NoteManager {
391560 const now = Date.now()
392561 let note = {
393562 id,
563 slug: noteSlug,
394564 title,
395565 content: content || '',
396566 tags: Array.isArray(tags) ? tags : [],
……@@ -416,6 +586,9 @@ export class NoteManager {
416586 await this.linkManager.updateLinks(notebook, id, note.content)
417587 }
418588
589 // Regenerate TOC in background
590 this.regenerateToc(notebook).catch(() => {})
591
419592 return note // Return plaintext to caller
420593 }
421594
……@@ -427,7 +600,11 @@ export class NoteManager {
427600 if (!note) return null
428601 const doc = await snb.reconstructDoc(noteId)
429602 const content = doc.getText('content').toString()
430 return { ...note, id: note.noteId, content }
603 const result = { ...note, id: note.noteId, content }
604 if (!result.slug) {
605 result.slug = slugify(result.title) || 'untitled-' + noteId.substring(0, 8)
606 }
607 return result
431608 }
432609
433610 // Existing Hyperdrive path
……@@ -442,7 +619,12 @@ export class NoteManager {
442619 if (!buf) return null
443620
444621 const note = JSON.parse(buf.toString())
445 return this._decryptNote(note, notebook)
622 const decrypted = this._decryptNote(note, notebook)
623 // Lazy slug migration
624 if (!decrypted.slug) {
625 decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
626 }
627 return decrypted
446628 }
447629
448630 async listNotes (notebook, { limit, offset } = {}) {
……@@ -455,17 +637,20 @@ export class NoteManager {
455637 try {
456638 const doc = await snb.reconstructDoc(note.noteId)
457639 const content = doc.getText('content').toString()
458 enriched.push({
640 const n = {
459641 id: note.noteId,
642 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
460643 title: note.title,
461644 content,
462645 tags: note.tags || [],
463646 createdAt: note.createdAt,
464647 updatedAt: note.updatedAt
465 })
648 }
649 enriched.push(n)
466650 } catch {
467651 enriched.push({
468652 id: note.noteId,
653 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
469654 title: note.title,
470655 content: '',
471656 tags: note.tags || [],
……@@ -474,6 +659,8 @@ export class NoteManager {
474659 })
475660 }
476661 }
662 // Deduplicate slugs
663 this._deduplicateSlugs(enriched)
477664 enriched.sort((a, b) => b.updatedAt - a.updatedAt)
478665 const start = offset || 0
479666 const end = limit ? start + limit : enriched.length
……@@ -488,6 +675,7 @@ export class NoteManager {
488675
489676 const drive = await this._getDrive(notebook)
490677 const notes = []
678 let needsPersist = false
491679
492680 try {
493681 const files = await drive.readdir('/notes')
……@@ -496,13 +684,30 @@ export class NoteManager {
496684 const buf = await drive.get(`/notes/${file}`)
497685 if (buf) {
498686 const note = JSON.parse(buf.toString())
499 notes.push(this._decryptNote(note, notebook))
687 const decrypted = this._decryptNote(note, notebook)
688 // Lazy slug migration
689 if (!decrypted.slug) {
690 decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
691 needsPersist = true
692 }
693 notes.push(decrypted)
500694 }
501695 }
502696 } catch {
503697 return []
504698 }
505699
700 // Deduplicate slugs
701 this._deduplicateSlugs(notes)
702
703 // Persist slugs lazily if any were missing (fire-and-forget)
704 if (needsPersist && !this.driveManager.isRemote(notebook)) {
705 for (const note of notes) {
706 const stored = (meta && meta.encrypted) ? this._encryptNote(note, notebook) : note
707 drive.put(`/notes/${note.id}.json`, Buffer.from(JSON.stringify(stored, null, 2))).catch(() => {})
708 }
709 }
710
506711 // Sort by updatedAt desc
507712 notes.sort((a, b) => b.updatedAt - a.updatedAt)
508713
……@@ -512,11 +717,30 @@ export class NoteManager {
512717 return notes.slice(start, end)
513718 }
514719
720 /**
721 * Deduplicate slugs within a list of notes in-place.
722 * Appends -2, -3 etc. to duplicate slugs.
723 */
724 _deduplicateSlugs (notes) {
725 const seen = new Map()
726 for (const note of notes) {
727 if (!note.slug) continue
728 if (seen.has(note.slug)) {
729 let i = 2
730 while (seen.has(note.slug + '-' + i)) i++
731 note.slug = note.slug + '-' + i
732 }
733 seen.set(note.slug, true)
734 }
735 }
736
515737 async updateNote (notebook, noteId, updates) {
738 const titleChanged = updates.title !== undefined
739
516740 // SharedNotebook path (multi-writer)
517741 const snb = this._getSharedNotebook(notebook)
518742 if (snb) {
519 if (updates.title !== undefined) {
743 if (titleChanged) {
520744 this.validateTitle(updates.title)
521745 await snb.updateNoteTitle(noteId, updates.title, 'user')
522746 }
……@@ -536,7 +760,10 @@ export class NoteManager {
536760 }
537761 const doc = await snb.reconstructDoc(noteId)
538762 const content = doc.getText('content').toString()
539 return { ...note, id: note.noteId, content }
763 const result = { ...note, id: note.noteId, content }
764 // Regenerate TOC if title changed
765 if (titleChanged) this.regenerateToc(notebook).catch(() => {})
766 return result
540767 }
541768
542769 // Existing Hyperdrive path
……@@ -546,9 +773,12 @@ export class NoteManager {
546773 throw new NotFoundError(`Note '${noteId}' not found in '${notebook}'`, 'note')
547774 }
548775
549 if (updates.title !== undefined) {
776 if (titleChanged) {
550777 this.validateTitle(updates.title)
551778 note.title = updates.title
779 // Regenerate slug from new title
780 const newBaseSlug = slugify(updates.title) || 'untitled'
781 note.slug = await this._ensureUniqueSlug(notebook, newBaseSlug, noteId)
552782 }
553783 if (updates.content !== undefined) note.content = updates.content
554784 if (updates.tags !== undefined) note.tags = Array.isArray(updates.tags) ? updates.tags : []
……@@ -566,6 +796,9 @@ export class NoteManager {
566796 await this.linkManager.updateLinks(notebook, noteId, note.content)
567797 }
568798
799 // Regenerate TOC if title changed
800 if (titleChanged) this.regenerateToc(notebook).catch(() => {})
801
569802 return note // Return plaintext
570803 }
571804
……@@ -574,6 +807,8 @@ export class NoteManager {
574807 const snb = this._getSharedNotebook(notebook)
575808 if (snb) {
576809 await snb.deleteNote(noteId, 'user')
810 // Regenerate TOC in background
811 this.regenerateToc(notebook).catch(() => {})
577812 return { deleted: true, id: noteId }
578813 }
579814
……@@ -592,17 +827,26 @@ export class NoteManager {
592827 await this.linkManager.removeAllLinks(notebook, noteId)
593828 }
594829
595 // Decrement noteCount
830 // Decrement noteCount and handle README deletion
596831 const meta = await this.getNotebook(notebook)
597832 if (meta) {
598833 meta.noteCount = Math.max(0, (meta.noteCount || 1) - 1)
599834 meta.updatedAt = Date.now()
835 // If deleting the README, clear readmeId
836 if (meta.readmeId === noteId) {
837 delete meta.readmeId
838 }
600839 const toStore = { ...meta }
601840 delete toStore.isLocal
602841 delete toStore.driveKey
603842 await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
604843 }
605844
845 // Regenerate TOC in background (only if we didn't just delete the README)
846 if (!meta || meta.readmeId !== noteId) {
847 this.regenerateToc(notebook).catch(() => {})
848 }
849
606850 return { deleted: true, id: noteId }
607851 }
608852
……@@ -775,6 +1019,7 @@ export class NoteManager {
7751019 const content = doc.getText('content').toString()
7761020 allNotes.push({
7771021 id: note.noteId,
1022 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
7781023 title: note.title,
7791024 content,
7801025 tags: note.tags || [],
……@@ -808,6 +1053,7 @@ export class NoteManager {
8081053 const note = JSON.parse(buf.toString())
8091054 allNotes.push({
8101055 id: note.id,
1056 slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
8111057 title: note.title,
8121058 content: note.content,
8131059 tags: note.tags || [],
……@@ -859,6 +1105,7 @@ export class NoteManager {
8591105 results.push({
8601106 notebook: slug,
8611107 id: note.noteId,
1108 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
8621109 title: note.title,
8631110 snippet,
8641111 updatedAt: note.updatedAt
……@@ -891,6 +1138,7 @@ export class NoteManager {
8911138 results.push({
8921139 notebook: slug,
8931140 id: raw.id,
1141 slug: raw.slug || 'encrypted-' + (raw.id || '').substring(0, 8),
8941142 title: '(encrypted)',
8951143 snippet: `Tags: ${raw.tags.join(', ')}`,
8961144 updatedAt: raw.updatedAt
……@@ -914,6 +1162,7 @@ export class NoteManager {
9141162 results.push({
9151163 notebook: slug,
9161164 id: note.id,
1165 slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
9171166 title: note.title,
9181167 snippet,
9191168 updatedAt: note.updatedAt
lib/routes/notebooks.js +64 -18
……@@ -32,6 +32,7 @@ function parseQuery (url) {
3232}
3333
3434const NOTE_ID_RE = /^[a-f0-9]{16}$/
35const NOTE_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
3536const MEDIA_FILENAME_RE = /^[a-zA-Z0-9_\-][a-zA-Z0-9_\-\.]{0,254}$/
3637const NOTEBOOK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$/
3738
……@@ -43,6 +44,28 @@ function validateNoteId (res, id) {
4344 return true
4445}
4546
47/**
48 * Validate a note identifier (slug or hex ID).
49 */
50function validateNoteIdentifier (res, identifier) {
51 if (NOTE_ID_RE.test(identifier)) return true
52 if (NOTE_SLUG_RE.test(identifier) && identifier.length <= 64) return true
53 json(res, { error: 'Invalid note identifier format' }, 400)
54 return false
55}
56
57/**
58 * Resolve a note by slug or hex ID. Returns the note or sends 404.
59 */
60async function resolveNoteOrFail (res, nm, notebook, identifier) {
61 const note = await nm.resolveNote(notebook, identifier)
62 if (!note) {
63 json(res, { error: 'Note not found', code: 'NOT_FOUND', status: 404 }, 404)
64 return null
65 }
66 return note
67}
68
4669function validateNotebookSlug (res, slug) {
4770 if (!NOTEBOOK_SLUG_RE.test(slug)) {
4871 json(res, { error: 'Invalid notebook slug format' }, 400)
……@@ -233,50 +256,73 @@ export function registerNotebookRoutes (router, server) {
233256 if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
234257 })
235258
236 // Get note (open read)
259 // Get note (open read) — accepts slug or hex ID
237260 router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => {
238 if (!validateNoteId(res, req.params.id)) return
239 const note = await nm.getNote(req.params.notebook, req.params.id)
240 if (!note) {
241 json(res, { error: 'Note not found', code: 'NOT_FOUND', status: 404 }, 404)
242 return
243 }
261 if (!validateNoteIdentifier(res, req.params.id)) return
262 const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
263 if (!note) return
244264 json(res, note)
245265 })
246266
247 // Update note
267 // Update note — accepts slug or hex ID
248268 router.patch('/api/notebooks/:notebook/notes/:id', async (req, res) => {
249 if (!validateNoteId(res, req.params.id)) return
269 if (!validateNoteIdentifier(res, req.params.id)) return
250270 await requireAuth(req, server, 'write', req.params.notebook)
271 const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
272 if (!note) return
251273 const body = await readJson(req)
252 const note = await nm.updateNote(req.params.notebook, req.params.id, {
274 const updated = await nm.updateNote(req.params.notebook, note.id, {
253275 title: body.title,
254276 content: body.content,
255277 tags: body.tags
256278 })
257 json(res, note)
279 json(res, updated)
258280 // Update site drive in background
259281 if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
260282 })
261283
262 // Delete note
284 // Delete note — accepts slug or hex ID
263285 router.delete('/api/notebooks/:notebook/notes/:id', async (req, res) => {
264 if (!validateNoteId(res, req.params.id)) return
286 if (!validateNoteIdentifier(res, req.params.id)) return
265287 await requireAuth(req, server, 'write', req.params.notebook)
266 const result = await nm.deleteNote(req.params.notebook, req.params.id)
288 const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
289 if (!note) return
290 const result = await nm.deleteNote(req.params.notebook, note.id)
267291 json(res, result)
268292 // Update site drive in background
269293 if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
270294 })
271295
272 // Move note
296 // Move note — accepts slug or hex ID
273297 router.post('/api/notebooks/:notebook/notes/:id/move', async (req, res) => {
274 if (!validateNoteId(res, req.params.id)) return
298 if (!validateNoteIdentifier(res, req.params.id)) return
275299 await requireAuth(req, server, 'write', req.params.notebook)
300 const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
301 if (!note) return
276302 const body = await readJson(req)
277303 await requireAuth(req, server, 'write', body.toNotebook)
278 const note = await nm.moveNote(req.params.notebook, req.params.id, body.toNotebook)
279 json(res, note)
304 const moved = await nm.moveNote(req.params.notebook, note.id, body.toNotebook)
305 json(res, moved)
306 })
307
308 // ==================== README ====================
309
310 // Get README (lazy migration — creates if missing)
311 router.get('/api/notebooks/:notebook/readme', async (req, res) => {
312 const readme = await nm.ensureReadme(req.params.notebook)
313 if (!readme) {
314 json(res, { error: 'README not available', code: 'NOT_FOUND', status: 404 }, 404)
315 return
316 }
317 json(res, readme)
318 })
319
320 // Regenerate TOC manually
321 router.post('/api/notebooks/:notebook/readme/regenerate', async (req, res) => {
322 await requireAuth(req, server, 'write', req.params.notebook)
323 await nm.regenerateToc(req.params.notebook)
324 const readme = await nm.getReadme(req.params.notebook)
325 json(res, readme || { regenerated: true })
280326 })
281327
282328 // ==================== MEDIA ====================
web/index.html +38 -3
……@@ -5,8 +5,43 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1">
66 <title>Hypernote</title>
77 <script>document.documentElement.setAttribute('data-theme',localStorage.getItem('hn-theme')||'dark')</script>
8 <link rel="stylesheet" href="web/css/app.css?v=23">
8 <link rel="stylesheet" href="web/css/app.css?v=24">
99 <link rel="stylesheet" href="web/vendor/toastui-editor.min.css">
10 <style>
11 .notebook-readme {
12 background: var(--bg-secondary, #1e1e2e);
13 border: 1px solid var(--border-color, #333);
14 border-radius: 8px;
15 padding: 16px;
16 margin-bottom: 20px;
17 }
18 .readme-header {
19 display: flex;
20 align-items: center;
21 justify-content: space-between;
22 margin-bottom: 12px;
23 }
24 .readme-label {
25 font-weight: bold;
26 font-size: 0.85rem;
27 text-transform: uppercase;
28 letter-spacing: 0.5px;
29 color: var(--text-muted, #888);
30 }
31 .readme-actions {
32 display: flex;
33 gap: 6px;
34 }
35 .readme-body {
36 font-size: 0.95rem;
37 line-height: 1.6;
38 }
39 .readme-body h1 { font-size: 1.4rem; margin-top: 0; }
40 .readme-body h2 { font-size: 1.1rem; }
41 .readme-body ul { padding-left: 20px; }
42 .readme-body a { color: var(--color-primary, #6c9eff); }
43 .readme-body a:hover { text-decoration: underline; }
44 </style>
1045</head>
1146<body>
1247 <div id="app">
……@@ -117,8 +152,8 @@
117152
118153 <script src="web/vendor/toastui-editor-all.min.js"></script>
119154 <script src="web/js/identity.js?v=27"></script>
120 <script src="web/js/api.js?v=27"></script>
155 <script src="web/js/api.js?v=28"></script>
121156 <script src="web/js/markdown.js?v=23"></script>
122 <script src="web/js/app.js?v=27"></script>
157 <script src="web/js/app.js?v=28"></script>
123158</body>
124159</html>
web/js/api.js +8 -4
……@@ -85,12 +85,16 @@ window.HnApi = (function () {
8585 updateNotebook: (slug, body) => request('PATCH', '/api/notebooks/' + encodeURIComponent(slug), body),
8686 deleteNotebook: (slug) => request('DELETE', '/api/notebooks/' + encodeURIComponent(slug)),
8787
88 // Notes
88 // Notes (id can be slug or hex ID)
8989 listNotes: (nb) => request('GET', '/api/notebooks/' + encodeURIComponent(nb) + '/notes'),
90 getNote: (nb, id) => request('GET', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + id),
90 getNote: (nb, id) => request('GET', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + encodeURIComponent(id)),
9191 createNote: (nb, body) => request('POST', '/api/notebooks/' + encodeURIComponent(nb) + '/notes', body),
92 updateNote: (nb, id, body) => request('PATCH', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + id, body),
93 deleteNote: (nb, id) => request('DELETE', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + id),
92 updateNote: (nb, id, body) => request('PATCH', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + encodeURIComponent(id), body),
93 deleteNote: (nb, id) => request('DELETE', '/api/notebooks/' + encodeURIComponent(nb) + '/notes/' + encodeURIComponent(id)),
94
95 // README
96 getReadme: (nb) => request('GET', '/api/notebooks/' + encodeURIComponent(nb) + '/readme'),
97 regenerateReadme: (nb) => request('POST', '/api/notebooks/' + encodeURIComponent(nb) + '/readme/regenerate'),
9498
9599 // Search
96100 search: (q, nb) => {
web/js/app.js +111 -27
……@@ -127,6 +127,8 @@
127127 currentNotebook: null,
128128 notes: [],
129129 currentNote: null,
130 readme: null,
131 readmeCollapsed: false,
130132 editing: false,
131133 editingId: null, // null = new note
132134 canWrite: false,
……@@ -228,9 +230,9 @@
228230
229231 var parts = path.split('/')
230232
231 // #/:notebook/:noteId/edit
233 // #/:notebook/:noteSlug/edit
232234 if (parts.length >= 3 && parts[2] === 'edit') {
233 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
235 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: decodeURIComponent(parts[1]) }
234236 }
235237
236238 // #/:notebook/new
……@@ -238,9 +240,9 @@
238240 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: null }
239241 }
240242
241 // #/:notebook/:noteId
243 // #/:notebook/:noteSlug
242244 if (parts.length >= 2) {
243 return { view: 'note', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
245 return { view: 'note', notebook: decodeURIComponent(parts[0]), noteId: decodeURIComponent(parts[1]) }
244246 }
245247
246248 // #/:notebook — check for ?link= param
……@@ -433,12 +435,12 @@
433435 noteLi.className += ' active'
434436 }
435437 noteLi.textContent = note.title || 'Untitled'
436 noteLi.addEventListener('click', (function (slug, id) {
438 noteLi.addEventListener('click', (function (slug, noteSlug) {
437439 return function (e) {
438440 e.stopPropagation()
439 navigate('#/' + encodeURIComponent(slug) + '/' + id)
441 navigate('#/' + encodeURIComponent(slug) + '/' + encodeURIComponent(noteSlug))
440442 }
441 })(nb.slug, note.id))
443 })(nb.slug, note.slug || note.id))
442444 noteUl.appendChild(noteLi)
443445 }
444446
……@@ -524,7 +526,7 @@
524526 var html = '<h1>Recent Notes</h1><div class="recent-feed">'
525527 for (var i = 0; i < notes.length; i++) {
526528 var n = notes[i]
527 var href = '#/' + encodeURIComponent(n.notebook) + '/' + n.id
529 var href = '#/' + encodeURIComponent(n.notebook) + '/' + encodeURIComponent(n.slug || n.id)
528530 var date = new Date(n.updatedAt)
529531 var timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
530532 var preview = (n.content || '').replace(/[#*`\[\]!<>]/g, '').substring(0, 200)
……@@ -619,6 +621,15 @@
619621 }
620622
621623 await loadNoteList(slug)
624
625 // Fetch README for this notebook
626 try {
627 var readmeData = await HnApi.getReadme(slug)
628 state.readme = readmeData.note || readmeData
629 } catch (err) {
630 state.readme = null
631 }
632
622633 renderNotebookCards(slug)
623634
624635 // Handle wiki-link redirect
……@@ -627,7 +638,7 @@
627638 return n.title && n.title.toLowerCase() === linkTitle.toLowerCase()
628639 })
629640 if (match) {
630 navigate('#/' + encodeURIComponent(slug) + '/' + match.id)
641 navigate('#/' + encodeURIComponent(slug) + '/' + encodeURIComponent(match.slug || match.id))
631642 }
632643 }
633644 } catch (err) {
……@@ -649,8 +660,77 @@
649660
650661 function renderNotebookCards (notebook) {
651662 $notebookNotes.innerHTML = ''
663
664 // Render README panel at top if available
665 if (state.readme) {
666 var readmePanel = document.createElement('div')
667 readmePanel.className = 'notebook-readme'
668
669 var readmeHeader = document.createElement('div')
670 readmeHeader.className = 'readme-header'
671 readmeHeader.innerHTML = '<span class="readme-label">README</span>'
672
673 var readmeActions = document.createElement('div')
674 readmeActions.className = 'readme-actions'
675
676 var toggleBtn = document.createElement('button')
677 toggleBtn.className = 'btn-sm readme-toggle'
678 toggleBtn.textContent = state.readmeCollapsed ? 'Show' : 'Hide'
679 toggleBtn.addEventListener('click', function () {
680 state.readmeCollapsed = !state.readmeCollapsed
681 toggleBtn.textContent = state.readmeCollapsed ? 'Show' : 'Hide'
682 readmeBody.style.display = state.readmeCollapsed ? 'none' : 'block'
683 })
684 readmeActions.appendChild(toggleBtn)
685
686 if (state.canWrite) {
687 var editReadmeBtn = document.createElement('button')
688 editReadmeBtn.className = 'btn-sm'
689 editReadmeBtn.textContent = 'Edit'
690 editReadmeBtn.addEventListener('click', function () {
691 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(state.readme.slug || state.readme.id) + '/edit')
692 })
693 readmeActions.appendChild(editReadmeBtn)
694 }
695 readmeHeader.appendChild(readmeActions)
696 readmePanel.appendChild(readmeHeader)
697
698 var readmeBody = document.createElement('div')
699 readmeBody.className = 'readme-body markdown-body'
700 // Render markdown, resolve TOC slug links relative to current notebook
701 var readmeHtml = window.renderMarkdown(rewriteMediaUrls(state.readme.content || ''))
702 readmeBody.innerHTML = readmeHtml
703 if (state.readmeCollapsed) readmeBody.style.display = 'none'
704 readmePanel.appendChild(readmeBody)
705
706 // Make TOC links navigate within the notebook
707 var tocLinks = readmeBody.querySelectorAll('a[href]')
708 for (var li = 0; li < tocLinks.length; li++) {
709 ;(function (link) {
710 var href = link.getAttribute('href')
711 // Relative slug links (no protocol, no /, no #)
712 if (href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('#') && !href.startsWith('mailto:')) {
713 link.setAttribute('href', '#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(href))
714 link.addEventListener('click', function (e) {
715 e.preventDefault()
716 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(href))
717 })
718 }
719 })(tocLinks[li])
720 }
721
722 $notebookNotes.appendChild(readmePanel)
723 }
724
725 // Get the readme ID to filter it from card list
726 var readmeId = state.readme ? state.readme.id : null
727
652728 for (var i = 0; i < state.notes.length; i++) {
653729 var note = state.notes[i]
730 // Skip README from card list
731 if (readmeId && note.id === readmeId) continue
732
733 var noteSlug = note.slug || note.id
654734 var card = document.createElement('div')
655735 card.className = 'note-card'
656736
……@@ -662,12 +742,12 @@
662742 editBtn.className = 'note-card-btn note-card-edit'
663743 editBtn.textContent = 'Edit'
664744 editBtn.title = 'Edit note'
665 editBtn.addEventListener('click', (function (id) {
745 editBtn.addEventListener('click', (function (ns) {
666746 return function (e) {
667747 e.stopPropagation()
668 navigate('#/' + encodeURIComponent(notebook) + '/' + id + '/edit')
748 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(ns) + '/edit')
669749 }
670 })(note.id))
750 })(noteSlug))
671751 var delBtn = document.createElement('button')
672752 delBtn.className = 'note-card-btn note-card-delete'
673753 delBtn.textContent = 'Delete'
……@@ -714,9 +794,9 @@
714794 meta.textContent = new Date(note.updatedAt || note.createdAt).toLocaleDateString()
715795 card.appendChild(meta)
716796
717 card.addEventListener('click', (function (id) {
718 return function () { navigate('#/' + encodeURIComponent(notebook) + '/' + id) }
719 })(note.id))
797 card.addEventListener('click', (function (ns) {
798 return function () { navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(ns)) }
799 })(noteSlug))
720800
721801 $notebookNotes.appendChild(card)
722802 }
……@@ -733,6 +813,7 @@
733813 await loadNoteList(notebook)
734814 }
735815
816 // noteId may be a slug or hex ID — API resolves both
736817 var data = await HnApi.getNote(notebook, noteId)
737818 state.currentNote = data.note || data
738819 showView('note')
……@@ -1180,7 +1261,7 @@
11801261 if (!state.canWrite) {
11811262 alert('You do not have write permissions.')
11821263 if (noteId) {
1183 navigate('#/' + encodeURIComponent(notebook) + '/' + noteId)
1264 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(noteId))
11841265 } else {
11851266 navigate('#/' + encodeURIComponent(notebook))
11861267 }
……@@ -1202,15 +1283,15 @@
12021283 }
12031284
12041285 state.editing = true
1205 state.editingId = noteId
12061286
12071287 var initialContent = ''
12081288
12091289 if (noteId) {
1210 // Edit existing
1290 // Edit existing — noteId may be a slug, resolve to hex ID
12111291 try {
12121292 var data = await HnApi.getNote(notebook, noteId)
12131293 var note = data.note || data
1294 state.editingId = note.slug || note.id // Use slug for API calls (server resolves both)
12141295 $editorTitle.value = note.title || ''
12151296 $editorTags.value = (note.tags || []).join(', ')
12161297 initialContent = rewriteMediaUrls(note.content)
……@@ -1222,6 +1303,7 @@
12221303 }
12231304 } else {
12241305 // New note
1306 state.editingId = null
12251307 $editorTitle.value = ''
12261308 $editorTags.value = ''
12271309 initialContent = ''
……@@ -1554,13 +1636,14 @@
15541636 try {
15551637 if (state.editingId) {
15561638 // Update existing note
1557 await HnApi.updateNote(notebook, state.editingId, { title: title, content: content, tags: tags })
1558 navigate('#/' + encodeURIComponent(notebook) + '/' + state.editingId)
1639 var updated = await HnApi.updateNote(notebook, state.editingId, { title: title, content: content, tags: tags })
1640 var updatedNote = updated.note || updated
1641 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(updatedNote.slug || state.editingId))
15591642 } else {
15601643 // Create new note
15611644 var data = await HnApi.createNote(notebook, { title: title, content: content, tags: tags })
15621645 var note = data.note || data
1563 navigate('#/' + encodeURIComponent(notebook) + '/' + note.id)
1646 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(note.slug || note.id))
15641647 }
15651648 await loadNotebooks()
15661649 } catch (err) {
……@@ -1603,7 +1686,7 @@
16031686
16041687 var pathDiv = document.createElement('div')
16051688 pathDiv.className = 'path'
1606 pathDiv.textContent = r.notebook + '/' + r.id
1689 pathDiv.textContent = r.notebook + '/' + (r.slug || r.id)
16071690 item.appendChild(pathDiv)
16081691
16091692 if (r.snippet) {
……@@ -1613,9 +1696,9 @@
16131696 item.appendChild(snippet)
16141697 }
16151698
1616 item.addEventListener('click', (function (nb, id) {
1617 return function () { navigate('#/' + encodeURIComponent(nb) + '/' + id) }
1618 })(r.notebook, r.id))
1699 item.addEventListener('click', (function (nb, noteSlug) {
1700 return function () { navigate('#/' + encodeURIComponent(nb) + '/' + encodeURIComponent(noteSlug)) }
1701 })(r.notebook, r.slug || r.id))
16191702
16201703 $searchResults.appendChild(item)
16211704 }
……@@ -2886,7 +2969,7 @@
28862969 // Note toolbar buttons
28872970 document.getElementById('btn-edit-note').addEventListener('click', function () {
28882971 if (state.currentNotebook && state.currentNote) {
2889 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.currentNote.id + '/edit')
2972 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + encodeURIComponent(state.currentNote.slug || state.currentNote.id) + '/edit')
28902973 }
28912974 })
28922975
……@@ -2900,7 +2983,8 @@
29002983 document.getElementById('btn-cancel-edit').addEventListener('click', function () {
29012984 if (state.currentNotebook) {
29022985 if (state.editingId) {
2903 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.editingId)
2986 var cancelSlug = (state.currentNote && state.currentNote.slug) || state.editingId
2987 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + encodeURIComponent(cancelSlug))
29042988 } else {
29052989 navigate('#/' + encodeURIComponent(state.currentNotebook.slug))
29062990 }