From 393a00e8dd697fa792c52cf9e154e23b5a7c9607 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 13:38:01 -0500 Subject: [PATCH] docs(nav): Administration section lives in docs/admin/; sidebar ordered by usability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural changes, one owner-approved plan: - Move the admin guides out of docs/user/ into their own docs/admin/ area — admin-guide.md becomes the Administration section landing page (pinned first), and the 32-line admin-library-management.md stub becomes admin/libraries.md, which the admin-docs build-out rebuilds into the real Libraries page next. Retired URLs 301 via a new docRedirects map in the docs HTTP handler (checked before .md canonicalization so even .md spellings one-hop to the new clean URL); the two inbound links (docs index, user-guide front door) updated. - Sidebar direct items now sort by usabilityOrder — an explicit, per-section display order — instead of alphabetically: User Guide mirrors the 'Where to go' table on user-guide.md, Devices orders Android → KOReader → Kobo (alphabetically Kobo used to sort ahead of KOReader), and Administration pre-lists its planned pages so each lands in place as the admin build writes it. Unlisted docs fall back to alphabetical after the listed ones. Display-order only by construction: the URL-driven expand/highlight machinery keys on containsURL membership and never sees sort order — nav tests updated to pin the new orders plus a real-tree test (TestBuildNavigationUsabilityOrder) guarding all three sections. --- docs/{user => admin}/admin-guide.md | 0 .../libraries.md} | 2 +- docs/index.md | 2 +- docs/user/user-guide.md | 2 +- internal/docs/http_handler.go | 23 ++++++ internal/docs/navigation.go | 70 +++++++++++++++++-- internal/docs/navigation_test.go | 68 ++++++++++++++++-- 7 files changed, 152 insertions(+), 15 deletions(-) rename docs/{user => admin}/admin-guide.md (100%) rename docs/{user/admin-library-management.md => admin/libraries.md} (97%) diff --git a/docs/user/admin-guide.md b/docs/admin/admin-guide.md similarity index 100% rename from docs/user/admin-guide.md rename to docs/admin/admin-guide.md diff --git a/docs/user/admin-library-management.md b/docs/admin/libraries.md similarity index 97% rename from docs/user/admin-library-management.md rename to docs/admin/libraries.md index dc741ab3..febe3c1f 100644 --- a/docs/user/admin-library-management.md +++ b/docs/admin/libraries.md @@ -1,4 +1,4 @@ -# Admin Library Management +# Libraries ## Adding Library Folders diff --git a/docs/index.md b/docs/index.md index d9d32b17..240dae1d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly. - [Themes](user/themes.md) - [Calibre Integration](user/calibre-integration.md) - [Profile Guide](user/profile-guide.md) - - [Admin Guide](user/admin-guide.md) + - [Admin Guide](admin/admin-guide.md) ### 👨‍💻 For Developers diff --git a/docs/user/user-guide.md b/docs/user/user-guide.md index 85b45f93..d6ba55c4 100644 --- a/docs/user/user-guide.md +++ b/docs/user/user-guide.md @@ -40,7 +40,7 @@ comes when you add a [device](devices/android-app.md). ## A note on admins Some screens (user management, server settings, library folders) are admin -territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are +territory; [Admin Guide](../admin/admin-guide.md) is the door, and the admin docs are being expanded separately. **Looking for developer documentation?** See the diff --git a/internal/docs/http_handler.go b/internal/docs/http_handler.go index 3041d33f..ec6bf492 100644 --- a/internal/docs/http_handler.go +++ b/internal/docs/http_handler.go @@ -18,6 +18,19 @@ type HTTPHandler struct { docs *DocsHandler } +// docRedirects maps retired doc paths (as passed to ShowDocumentation — +// no leading /docs, .md suffix optional) to their current paths. Docs move +// rarely; when one moves, add it here so old links 301 to the new home +// instead of 404ing. Checked BEFORE .md canonicalization so a moved page's +// .md spelling also one-hops straight to the new clean URL. +// 2026-11-02: admin guides moved from docs/user/ to docs/admin/, and +// admin-library-management.md became admin/libraries.md (it will be +// rebuilt as the Libraries page). +var docRedirects = map[string]string{ + "user/admin-guide": "admin/admin-guide", + "user/admin-library-management": "admin/libraries", +} + // NewHTTPHandler creates a new docs HTTP handler func NewHTTPHandler(docsPath string) *HTTPHandler { return &HTTPHandler{ @@ -76,6 +89,16 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error { // Get the document path from URL parameter docPath := c.Param("*") + // Retired doc paths 301 to their current homes (see docRedirects) + // before any load attempt. + if target, moved := docRedirects[strings.TrimSuffix(docPath, ".md")]; moved { + loc := "/docs/" + target + if c.Request().URL.RawQuery != "" { + loc += "?" + c.Request().URL.RawQuery + } + return c.Redirect(http.StatusMovedPermanently, loc) + } + // Canonicalize ".md" URLs to the clean form. Markdown links across the // docs tree point at `area/file.md`; the handler loads them fine, but // the sidebar logic keys on the URL path (contains-URL expansion, the diff --git a/internal/docs/navigation.go b/internal/docs/navigation.go index 8a16c36d..b798f50e 100644 --- a/internal/docs/navigation.go +++ b/internal/docs/navigation.go @@ -28,6 +28,47 @@ var sectionOrder = []struct { {Title: "Contributing", Icon: "heart"}, } +// usabilityOrder pins an explicit, usability-first display order for a +// section's direct nav items, overriding the alphabetical fallback (owner +// request 2026-11-02: order the menu by how people work, not by letter — +// the User Guide list mirrors the "Where to go" table on user-guide.md). +// Values are doc paths without the .md suffix; anything in the section not +// listed sorts alphabetically AFTER the listed items. Display-only: the +// open/highlight machinery keys on URL membership (containsURL) and is +// order-blind, so URL-driven expansion is unaffected. Administration lists +// its planned pages up front so each lands in place as it is written. +var usabilityOrder = map[string][]string{ + "User Guide": { + "user/dashboard", + "user/library-browsing", + "user/collections", + "user/searching-and-filtering", + "user/podcasts", + "user/reader-guide", + "user/themes", + "user/profile-guide", + "user/sync-guide", + "user/calibre-integration", + }, + "Devices": { + "user/devices/android-app", + "user/devices/koreader-setup", + "user/devices/kobo-setup", + }, + "Administration": { + "admin/admin-guide", + "admin/dashboard", + "admin/libraries", + "admin/library-processing-issues", + "admin/archived-items", + "admin/podcasts", + "admin/hash-conflicts", + "admin/users", + "admin/settings", + "admin/scanning", + }, +} + // groupTitleOverrides fixes casing that naive title-casing gets wrong. var groupTitleOverrides = map[string]string{ "admin": "Admin", @@ -104,12 +145,16 @@ func placeDoc(docPath string) navPlacement { if rest == "user-guide.md" { return navPlacement{section: "User Guide", pin: -50} } - // Admin-facing guides are their own top-level section too. - if strings.HasPrefix(rest, "admin-") { - return navPlacement{section: "Administration"} - } return navPlacement{section: "User Guide"} + case strings.HasPrefix(docPath, "admin/"): + // Admin docs live in their own area (docs/admin/); the guide is + // the section landing page. + if docPath == "admin/admin-guide.md" { + return navPlacement{section: "Administration", pin: -50} + } + return navPlacement{section: "Administration"} + case strings.HasPrefix(docPath, "developer/api/"): rest := strings.TrimPrefix(docPath, "developer/api/") if dir, _, ok := strings.Cut(rest, "/"); ok { @@ -256,14 +301,27 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation build := builds[spec.Title] section := templates.NavSection{Title: spec.Title, Icon: spec.Icon} - // Direct items: pinned landing pages lead, then alphabetical - // (case-insensitive — KOReader must not sort before Kobo). + // Direct items: pinned landing pages lead, then usability order + // (see usabilityOrder), then alphabetical among anything unlisted. + order := usabilityOrder[spec.Title] + rankOf := func(url string) int { + for i, p := range order { + if docURL(p) == url { + return i + } + } + return len(order) + } items := append([]templates.NavItem(nil), build.items...) sort.SliceStable(items, func(i, j int) bool { pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL] if pi != pj { return pi } + ri, rj := rankOf(items[i].URL), rankOf(items[j].URL) + if ri != rj { + return ri < rj + } return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title) }) section.Items = items diff --git a/internal/docs/navigation_test.go b/internal/docs/navigation_test.go index 03b2a533..5a582eac 100644 --- a/internal/docs/navigation_test.go +++ b/internal/docs/navigation_test.go @@ -3,6 +3,7 @@ package docs import ( "os" "path/filepath" + "reflect" "testing" "bookhoard/templates" @@ -18,7 +19,8 @@ func TestPlaceDoc(t *testing.T) { {"index.md", "Getting Started", "", true}, {"user/user-guide.md", "User Guide", "", true}, {"user/dashboard.md", "User Guide", "", false}, - {"user/admin-guide.md", "Administration", "", false}, + {"admin/admin-guide.md", "Administration", "", true}, + {"admin/libraries.md", "Administration", "", false}, {"user/devices/kobo-setup.md", "Devices", "", false}, {"developer/api/api-reference.md", "API Reference", "", true}, {"developer/api/dashboard.md", "API Reference", "", false}, @@ -52,6 +54,7 @@ func TestDocTitle(t *testing.T) { tests := map[string]string{ "index.md": "Overview", "user/user-guide.md": "User Guide", + "admin/admin-guide.md": "Admin Guide", "user/searching-and-filtering.md": "Searching and Filtering", "user/devices/koreader-setup.md": "KOReader Setup", "developer/development.md": "Developer Portal", @@ -86,7 +89,7 @@ func buildTestDocs(t *testing.T) *DocsHandler { "index.md", "user/user-guide.md", "user/dashboard.md", - "user/admin-guide.md", + "admin/admin-guide.md", "user/devices/kobo-setup.md", "user/devices/koreader-setup.md", "developer/api/api-reference.md", @@ -148,8 +151,11 @@ func TestBuildNavigation(t *testing.T) { t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items) } - // Devices: own top-level section, expanded, no groups. (Administration - // sits above it in the order; covered by the collapsed sweep below.) + // Devices: own top-level section, expanded, no groups. KOReader sorts + // ahead of Kobo because usabilityOrder ranks the device guides by how + // the docs front door presents them (Android → KOReader → Kobo), not + // alphabetically. (Administration sits above it in the section order; + // covered by the collapsed sweep below.) devices := nav.Sections[3] if devices.Collapsed { t.Error("Devices section should be expanded: it contains the current page") @@ -157,8 +163,8 @@ func TestBuildNavigation(t *testing.T) { if len(devices.Groups) != 0 { t.Errorf("Devices groups = %+v, want none", devices.Groups) } - if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" { - t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items) + if len(devices.Items) != 2 || devices.Items[0].Title != "KOReader Setup" { + t.Errorf("Devices items = %+v, want KOReader Setup before Kobo Setup (usability order)", devices.Items) } // Everything else starts collapsed (Administration included). @@ -194,6 +200,56 @@ func TestBuildNavigation(t *testing.T) { } } +// TestBuildNavigationUsabilityOrder (2026-11-02): the REAL docs tree must +// present its direct nav items in usability order (usabilityOrder), not +// alphabetically — User Guide mirrors the "Where to go" table on +// user-guide.md, Devices orders Android → KOReader → Kobo, Administration +// mirrors the app's own admin sidebar. Display-order only: expansion and +// highlighting stay URL-driven (order-blind) by construction. +func TestBuildNavigationUsabilityOrder(t *testing.T) { + handler := NewDocsHandler("../../docs") + nav := handler.BuildNavigation("/docs") + + find := func(title string) *templates.NavSection { + for i := range nav.Sections { + if nav.Sections[i].Title == title { + return &nav.Sections[i] + } + } + t.Fatalf("section %q missing", title) + return nil + } + + titles := func(s *templates.NavSection) []string { + out := make([]string, 0, len(s.Items)) + for _, item := range s.Items { + out = append(out, item.Title) + } + return out + } + + wantUserGuide := []string{ + "User Guide", "Dashboard", "Library Browsing", "Collections", + "Searching and Filtering", "Podcasts", "Reader Guide", "Themes", + "Profile Guide", "Sync Guide", "Calibre Integration", + } + if got := titles(find("User Guide")); !reflect.DeepEqual(got, wantUserGuide) { + t.Errorf("User Guide order = %v, want %v", got, wantUserGuide) + } + + wantDevices := []string{"Android App", "KOReader Setup", "Kobo Setup"} + if got := titles(find("Devices")); !reflect.DeepEqual(got, wantDevices) { + t.Errorf("Devices order = %v, want %v", got, wantDevices) + } + + // Administration grows as its pages are written; pin what exists today: + // the guide leads (pinned landing), Libraries follows. + wantAdmin := []string{"Admin Guide", "Libraries"} + if got := titles(find("Administration")); !reflect.DeepEqual(got, wantAdmin) { + t.Errorf("Administration order = %v, want %v", got, wantAdmin) + } +} + func TestBuildNavigationNothingCurrent(t *testing.T) { h := buildTestDocs(t) nav := h.BuildNavigation("/docs/nonexistent")