docs(nav): Administration section lives in docs/admin/; sidebar ordered by usability
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.
This commit is contained in:
1 parent
7e2e1c4c60
commit
393a00e8dd
7 files changed
+152
-15
No files matched your search
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
# Admin Library Management
|
# Libraries
|
||||||
|
|
||||||
## Adding Library Folders
|
## Adding Library Folders
|
||||||
|
|
||||||
+1
-1
@@ -32,7 +32,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
- [Themes](user/themes.md)
|
- [Themes](user/themes.md)
|
||||||
- [Calibre Integration](user/calibre-integration.md)
|
- [Calibre Integration](user/calibre-integration.md)
|
||||||
- [Profile Guide](user/profile-guide.md)
|
- [Profile Guide](user/profile-guide.md)
|
||||||
- [Admin Guide](user/admin-guide.md)
|
- [Admin Guide](admin/admin-guide.md)
|
||||||
|
|
||||||
### 👨💻 For Developers
|
### 👨💻 For Developers
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ comes when you add a [device](devices/android-app.md).
|
|||||||
## A note on admins
|
## A note on admins
|
||||||
|
|
||||||
Some screens (user management, server settings, library folders) are admin
|
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.
|
being expanded separately.
|
||||||
|
|
||||||
**Looking for developer documentation?** See the
|
**Looking for developer documentation?** See the
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ type HTTPHandler struct {
|
|||||||
docs *DocsHandler
|
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
|
// NewHTTPHandler creates a new docs HTTP handler
|
||||||
func NewHTTPHandler(docsPath string) *HTTPHandler {
|
func NewHTTPHandler(docsPath string) *HTTPHandler {
|
||||||
return &HTTPHandler{
|
return &HTTPHandler{
|
||||||
@@ -76,6 +89,16 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
|
|||||||
// Get the document path from URL parameter
|
// Get the document path from URL parameter
|
||||||
docPath := c.Param("*")
|
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
|
// Canonicalize ".md" URLs to the clean form. Markdown links across the
|
||||||
// docs tree point at `area/file.md`; the handler loads them fine, but
|
// 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
|
// the sidebar logic keys on the URL path (contains-URL expansion, the
|
||||||
|
|||||||
@@ -28,6 +28,47 @@ var sectionOrder = []struct {
|
|||||||
{Title: "Contributing", Icon: "heart"},
|
{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.
|
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
|
||||||
var groupTitleOverrides = map[string]string{
|
var groupTitleOverrides = map[string]string{
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
@@ -104,12 +145,16 @@ func placeDoc(docPath string) navPlacement {
|
|||||||
if rest == "user-guide.md" {
|
if rest == "user-guide.md" {
|
||||||
return navPlacement{section: "User Guide", pin: -50}
|
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"}
|
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/"):
|
case strings.HasPrefix(docPath, "developer/api/"):
|
||||||
rest := strings.TrimPrefix(docPath, "developer/api/")
|
rest := strings.TrimPrefix(docPath, "developer/api/")
|
||||||
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
||||||
@@ -256,14 +301,27 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
|||||||
build := builds[spec.Title]
|
build := builds[spec.Title]
|
||||||
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
|
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
|
||||||
|
|
||||||
// Direct items: pinned landing pages lead, then alphabetical
|
// Direct items: pinned landing pages lead, then usability order
|
||||||
// (case-insensitive — KOReader must not sort before Kobo).
|
// (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...)
|
items := append([]templates.NavItem(nil), build.items...)
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
|
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
|
||||||
if pi != pj {
|
if pi != pj {
|
||||||
return pi
|
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)
|
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
|
||||||
})
|
})
|
||||||
section.Items = items
|
section.Items = items
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package docs
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
@@ -18,7 +19,8 @@ func TestPlaceDoc(t *testing.T) {
|
|||||||
{"index.md", "Getting Started", "", true},
|
{"index.md", "Getting Started", "", true},
|
||||||
{"user/user-guide.md", "User Guide", "", true},
|
{"user/user-guide.md", "User Guide", "", true},
|
||||||
{"user/dashboard.md", "User Guide", "", false},
|
{"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},
|
{"user/devices/kobo-setup.md", "Devices", "", false},
|
||||||
{"developer/api/api-reference.md", "API Reference", "", true},
|
{"developer/api/api-reference.md", "API Reference", "", true},
|
||||||
{"developer/api/dashboard.md", "API Reference", "", false},
|
{"developer/api/dashboard.md", "API Reference", "", false},
|
||||||
@@ -52,6 +54,7 @@ func TestDocTitle(t *testing.T) {
|
|||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"index.md": "Overview",
|
"index.md": "Overview",
|
||||||
"user/user-guide.md": "User Guide",
|
"user/user-guide.md": "User Guide",
|
||||||
|
"admin/admin-guide.md": "Admin Guide",
|
||||||
"user/searching-and-filtering.md": "Searching and Filtering",
|
"user/searching-and-filtering.md": "Searching and Filtering",
|
||||||
"user/devices/koreader-setup.md": "KOReader Setup",
|
"user/devices/koreader-setup.md": "KOReader Setup",
|
||||||
"developer/development.md": "Developer Portal",
|
"developer/development.md": "Developer Portal",
|
||||||
@@ -86,7 +89,7 @@ func buildTestDocs(t *testing.T) *DocsHandler {
|
|||||||
"index.md",
|
"index.md",
|
||||||
"user/user-guide.md",
|
"user/user-guide.md",
|
||||||
"user/dashboard.md",
|
"user/dashboard.md",
|
||||||
"user/admin-guide.md",
|
"admin/admin-guide.md",
|
||||||
"user/devices/kobo-setup.md",
|
"user/devices/kobo-setup.md",
|
||||||
"user/devices/koreader-setup.md",
|
"user/devices/koreader-setup.md",
|
||||||
"developer/api/api-reference.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)
|
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices: own top-level section, expanded, no groups. (Administration
|
// Devices: own top-level section, expanded, no groups. KOReader sorts
|
||||||
// sits above it in the order; covered by the collapsed sweep below.)
|
// 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]
|
devices := nav.Sections[3]
|
||||||
if devices.Collapsed {
|
if devices.Collapsed {
|
||||||
t.Error("Devices section should be expanded: it contains the current page")
|
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 {
|
if len(devices.Groups) != 0 {
|
||||||
t.Errorf("Devices groups = %+v, want none", devices.Groups)
|
t.Errorf("Devices groups = %+v, want none", devices.Groups)
|
||||||
}
|
}
|
||||||
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
|
if len(devices.Items) != 2 || devices.Items[0].Title != "KOReader Setup" {
|
||||||
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
|
t.Errorf("Devices items = %+v, want KOReader Setup before Kobo Setup (usability order)", devices.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else starts collapsed (Administration included).
|
// 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) {
|
func TestBuildNavigationNothingCurrent(t *testing.T) {
|
||||||
h := buildTestDocs(t)
|
h := buildTestDocs(t)
|
||||||
nav := h.BuildNavigation("/docs/nonexistent")
|
nav := h.BuildNavigation("/docs/nonexistent")
|
||||||
|
|||||||
Reference in new issue
Block a user