The feature branch is currently based on main (old file-based, templ-template architecture). The develop branch has a completely different, more advanced architecture: VanJS SPA, REST JSON API, SQLite-based storage (modernc.org/sqlite), Playwright e2e tests. The feature must be built on top of develop.
The develop branch already has:
- SQLite DB with
notes,notes_fts,attachments,snapshotstables - REST API at
/api/notes,/api/search, etc. - VanJS SPA frontend in
staticfs/js/app.js+staticfs/js/api.js - Playwright e2e tests in
e2e/
Goal: add notebook hierarchy as new tables in the same SQLite DB, new REST endpoints, VanJS sidebar/components, and Playwright e2e tests. Do NOT modify the notes table.
Working branch: claude/add-notebook-hierarchy-utGhO
The feature branch must be reset to origin/develop before any work begins:
git fetch origin develop
git reset --hard origin/developAdd to internal/store/migrate.go migrateSQL const:
CREATE TABLE IF NOT EXISTS notebooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES notebooks(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_notebooks (
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, notebook_id)
);
-- Seed Default notebook (id=1, protected from deletion)
INSERT OR IGNORE INTO notebooks (id, name, parent_id, created_at)
VALUES (1, 'Default', NULL, strftime('%s','now') * 1000);
-- Assign all existing unassigned notes to Default on first migration
INSERT OR IGNORE INTO note_notebooks (note_id, notebook_id)
SELECT id, 1 FROM notes
WHERE id NOT IN (SELECT note_id FROM note_notebooks);Using note_id INTEGER FK (not title) means renames preserve assignments automatically.
Append the notebook SQL block above to the existing migrateSQL constant.
// Notebook model
type Notebook struct {
ID int64
Name string
ParentID *int64
CreatedAt time.Time
}
// Store methods to add:
func (s *Store) ListNotebooks() ([]Notebook, error)
func (s *Store) GetNotebook(id int64) (Notebook, error)
func (s *Store) CreateNotebook(name string, parentID *int64) (int64, error)
func (s *Store) UpdateNotebook(id int64, name string, parentID *int64) error
func (s *Store) DeleteNotebook(id int64) error // blocks id=1
func (s *Store) ListNotesByNotebook(notebookID int64) ([]noteListItem, error)
func (s *Store) AssignNoteToNotebook(noteID, notebookID int64) error // INSERT OR REPLACE
func (s *Store) EnsureDefaultNotebook(noteID int64) error // assign to 1 if unassignednoteListItem is the existing struct already used by APINotesList.
Response types:
type apiNotebook struct {
ID int64 `json:"id"`
Name string `json:"name"`
ParentID *int64 `json:"parent_id"`
CreatedAt int64 `json:"created_at"`
}New handlers:
// GET /api/notebooks → []apiNotebook (flat list, frontend builds tree)
func (h *Handler) APIListNotebooks(w, r)
// POST /api/notebooks → {name, parent_id?} → 201 + apiNotebook
func (h *Handler) APICreateNotebook(w, r)
// PUT /api/notebooks/{id} → {name, parent_id?} → 200 + apiNotebook
func (h *Handler) APIUpdateNotebook(w, r)
// DELETE /api/notebooks/{id} → 204; 403 if id==1
func (h *Handler) APIDeleteNotebook(w, r)
// GET /api/notebooks/{id}/notes → [{title, updated_at}]
func (h *Handler) APINotebookNotes(w, r)Update APICreateNote: after creating the note, read optional notebook_id from JSON body; if present and > 0, call AssignNoteToNotebook; else call EnsureDefaultNotebook.
Update apiCreateBody to add NotebookID *int64 \json:"notebook_id"``.
Inside the protected group:
r.Get("/api/notebooks", h.APIListNotebooks)
r.Post("/api/notebooks", h.APICreateNotebook)
r.Put("/api/notebooks/{id}", h.APIUpdateNotebook)
r.Delete("/api/notebooks/{id}", h.APIDeleteNotebook)
r.Get("/api/notebooks/{id}/notes", h.APINotebookNotes)
// SPA shell for notebook routes
r.Get("/notebooks/new", h.AppShell)
r.Get("/notebooks/{id}", h.AppShell)
r.Get("/notebooks/{id}/edit", h.AppShell)Add these exported functions:
/** @returns {Promise<Array<{id:number,name:string,parent_id:number|null,created_at:number}>>} */
export function listNotebooks()
/** @param {{name:string, parent_id?:number|null}} body */
export function createNotebook(body)
/** @param {number} id, @param {{name:string, parent_id?:number|null}} body */
export function updateNotebook(id, body)
/** @param {number} id */
export function deleteNotebook(id)
/** @param {number} id */
export function getNotebookNotes(id)New global state:
const sidebarOpen = state(false);
const notebooks = state([]); // flat list, refreshed on sidebar openHelper: buildNotebookTree(flatList)
Builds {id, name, parent_id, children:[]} tree from flat array.
New/updated functions:
hamburgerSvg() — 3-line SVG icon (like logoSvg())
Updated SiteHeader() — replace the <a class="header-logo"> element with:
button({
id: "menu-btn",
class: "btn btn-ghost header-menu-btn",
"aria-label": "Open menu",
onclick: () => {
api.listNotebooks().then(d => notebooks.val = d).catch(() => {});
sidebarOpen.val = true;
}
}, hamburgerSvg())NotebookTreeNode(node, depth) — recursive VanJS component for one notebook item + its children:
function NotebookTreeNode(node, depth = 0) {
return div({ class: "nb-tree-item", style: `padding-left: ${depth * 1}rem` },
a({
class: "sidebar-link",
href: appHref("/notebooks/" + node.id),
onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/notebooks/" + node.id); }
}, node.name),
...node.children.map(c => NotebookTreeNode(c, depth + 1))
);
}Sidebar() — full sidebar overlay component:
function Sidebar() {
return div(
div({ // backdrop
class: () => "sidebar-backdrop" + (sidebarOpen.val ? " sidebar-backdrop-visible" : ""),
onclick: () => sidebarOpen.val = false
}),
aside({ // panel
class: () => "sidebar" + (sidebarOpen.val ? " sidebar-open" : "")
},
div({ class: "sidebar-header" },
span({ class: "sidebar-title" }, "Notebooks"),
button({ class: "btn btn-ghost sidebar-close-btn",
onclick: () => sidebarOpen.val = false }, "✕")
),
nav({ class: "sidebar-nav" },
a({ class: "sidebar-link sidebar-link-all",
href: appHref("/"),
onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/"); }
}, "All Notes"),
() => {
const tree = buildNotebookTree(notebooks.val);
return div({ class: "sidebar-notebooks" },
...tree.map(n => NotebookTreeNode(n, 0))
);
}
),
div({ class: "sidebar-footer" },
a({ class: "btn btn-secondary sidebar-new-notebook",
href: appHref("/notebooks/new"),
onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/notebooks/new"); }
}, "+ New Notebook")
)
)
);
}NotebookView(notebookId) — like HomeView() but filtered:
- Fetches
api.getNotebookNotes(notebookId)andapi.getNotebook(notebookId)(or use data from sidebar) - Shows notebook name as title
- Shows filtered note list
- Shows "+ New Note" →
navigateTo("/notes/new?notebook_id=" + notebookId) - Shows "Edit" button →
navigateTo("/notebooks/" + notebookId + "/edit")
NewNotebookView(parentId?) — form to create notebook:
- Name input, parent selector (from
notebooksstate) - Submit →
api.createNotebook(...)→ navigate to/notebooks/{id}
EditNotebookView(notebookId) — form to edit:
- Pre-fills name + parent
- Submit →
api.updateNotebook(...)→ navigate to/notebooks/{id} - Delete button (disabled if id=1)
Update isInternalAppPath to add:
if (path === "/notebooks/new") return true;
if (path.startsWith("/notebooks/")) return true;Update parseRouteFromPathAndSearch to add:
if (path === "/notebooks/new") {
const parentId = sp.get("parent_id");
return { name: "new-notebook", parentId: parentId ? parseInt(parentId) : null };
}
const nbEditMatch = path.match(/^\/notebooks\/(\d+)\/edit$/);
if (nbEditMatch) return { name: "edit-notebook", id: parseInt(nbEditMatch[1]) };
const nbMatch = path.match(/^\/notebooks\/(\d+)$/);
if (nbMatch) return { name: "notebook", id: parseInt(nbMatch[1]) };Update MainBody() router switch:
case "notebook": return r.id ? NotebookView(r.id) : HomeView();
case "new-notebook": return NewNotebookView(r.parentId);
case "edit-notebook": return r.id ? EditNotebookView(r.id) : HomeView();Update App() to include Sidebar():
function App() {
return div({ class: "app-layout" },
SiteHeader(),
Sidebar(),
div({ class: "content-scroll" }, () => MainBody())
);
}Update NewNote flow: when route.val.name === "new" and a notebook_id query param exists, pass it through to the note creation so APICreateNote auto-assigns.
.header-menu-btn/.header-menu-icon— replaces logo styles.sidebar-backdrop/.sidebar-backdrop-visible— dark overlay.sidebar/.sidebar-open— slide-in panel (width 280px, transform).sidebar-header/.sidebar-title/.sidebar-close-btn.sidebar-nav/.sidebar-link/.sidebar-link-all.sidebar-footer/.sidebar-new-notebook.sidebar-notebooks/.nb-tree-item— tree with depth indentation.notebook-page-header/.notebook-page-title/.notebook-page-actions.notebook-form/.notebook-select/.form-actions
All tests use startTestApp(t) + newPlaywrightPage(t) (same patterns as existing tests).
Tests to implement:
// TestNotebookHierarchy_3Levels
// Creates a 3-level hierarchy and notes at each level:
// Root "Work" → child "Projects" → grandchild "snotes-go"
// Creates a note in each notebook, verifies each appears in its notebook page.
// TestNotebook_CreateAndAppearInSidebar
// Creates a notebook, opens sidebar, verifies it appears.
// TestNotebook_CreateNote_AssignedToNotebook
// Opens a notebook page, clicks "+ New Note", creates note,
// navigates back to notebook, verifies note is listed there.
// TestNotebook_DefaultNotebook
// Creates a note from the home page (no notebook context),
// verifies it appears in the Default notebook.
// TestNotebook_DeleteNonDefault
// Creates a notebook, deletes it, verifies it's gone from sidebar.
// TestNotebook_CannotDeleteDefault
// Tries to delete Default notebook, verifies it's blocked.
// TestNotebook_RenameNotebook
// Creates a notebook, edits it with a new name, verifies the new name shows.| File | Change |
|---|---|
internal/store/migrate.go |
Append notebooks + note_notebooks SQL |
internal/store/store.go |
Add Notebook model + 7 store methods |
handlers/api.go |
Add 5 notebook handlers; update CreateNote for notebook_id |
internal/httpserver/router.go |
Register 5 new API routes + 3 SPA shell routes |
staticfs/js/api.js |
Add 5 notebook API functions |
staticfs/js/app.js |
Add Sidebar, NotebookTreeNode, NotebookView, NewNotebookView, EditNotebookView; update SiteHeader, MainBody, App, routing |
staticfs/style.css |
Append sidebar + notebook CSS |
e2e/notebooks_test.go |
New file: 7 Playwright e2e tests |
git reset --hard origin/develop— start from developmake generate && go build ./...— compiles cleanlygo test ./internal/store/... ./handlers/...— unit tests passmake dev— server starts- Hamburger button opens sidebar; All Notes link works; sidebar closes on backdrop click / Escape
- Create notebook → appears in sidebar tree
- Create sub-notebook → appears indented under parent
- 3-level hierarchy renders correctly
- Create note from notebook page → auto-assigned; appears in notebook view
- New note from home → auto-assigned to Default
go test -tags=e2e ./e2e/...— all Playwright tests pass