package main import ( "bytes" "html/template" "log" "net/http" "os" "path" "path/filepath" "sort" "strings" ) var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html")) // treeOpts selects what a folder tree renders. The same markup serves the // sidebar (links) and the tree picker (radio inputs named "pick"). type treeOpts struct { Files bool // list files, not just folders Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind Active string // wiki path of the current page, highlighted (sidebar) Select string // wiki path of the initially checked row (picker) } // query returns the ?tree query that lazy-loads a folder's children with the // same options. func (o treeOpts) query() string { q := "?tree" if o.Files { q += "&files=1" } if o.Pick != "" { q += "&pick=" + o.Pick } return q } type treeEntry struct { Name string Kind string // "folder" or "file" // HasChildren reports whether a folder has any visible entries (under the // same files filter), so empty folders render without a chevron. HasChildren bool } // treeRow is one rendered row. Folders with children are expandable: either // pre-expanded (Children set, along the requested path) or lazy-loaded from // ChildrenURL when first opened. type treeRow struct { Name, Path, Kind string URL string // page URL (sidebar links) Link bool // render the name as a link Selectable bool // render a picker radio Active, Checked bool Expandable bool Children []treeRow ChildrenURL string } // handleTree responds with the rendered rows of the folder at fsPath: // // ?tree sidebar rows (folders only, links) // &files=1 include files // &pick=folder|file|any picker rows selecting that kind // &expandTo=/a/b pre-expand the chain down to /a/b // &select=/a/b check that row // &root=1 lead with a row for "/" itself (picker) func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { info, err := os.Stat(fsPath) if err != nil { if os.IsNotExist(err) { http.NotFound(w, r) return } http.Error(w, "stat failed", http.StatusInternalServerError) return } if !info.IsDir() { http.Error(w, "not a folder", http.StatusBadRequest) return } q := r.URL.Query() o := treeOpts{Files: q.Has("files"), Select: q.Get("select")} switch p := q.Get("pick"); p { case "", "folder", "file", "any": o.Pick = p default: http.Error(w, "bad pick mode", http.StatusBadRequest) return } dir := normalizeMovePath(urlPath) var expand []string if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) && strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") { expand = segs[len(base):] } rows, err := buildTreeRows(fsPath, dir, expand, o) if err != nil { http.Error(w, "read failed", http.StatusInternalServerError) return } if dir == "/" && q.Has("root") && o.Pick != "" { root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o) rows = append([]treeRow{root}, rows...) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil { log.Printf("tree template error: %v", err) } } // sidebarTree renders the navigation tree for the page at urlPath: the root // listing with the chain down to the page expanded and the page highlighted. // The tree is part of the page itself, so the rail never paints empty. func sidebarTree(root, urlPath string) template.HTML { page := normalizeMovePath(urlPath) rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page}) if err != nil { return "" } var buf bytes.Buffer if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil { log.Printf("tree template error: %v", err) return "" } return template.HTML(buf.String()) } // buildTreeRows lists the folder fsPath (wiki path dir) and expands the // folder named by expand[0], recursing down the chain. The walk stops at the // deepest segment that still exists. Segments only ever match real directory // names from listTreeEntries, so this cannot traverse outside the tree. func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) { entries, err := listTreeEntries(fsPath, !o.Files) if err != nil { return nil, err } rows := make([]treeRow, len(entries)) for i, e := range entries { p := path.Join(dir, e.Name) rows[i] = newTreeRow(e, p, o) if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren { kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o) if err == nil { rows[i].Children = kids } } } return rows, nil } func newTreeRow(e treeEntry, p string, o treeOpts) treeRow { row := treeRow{ Name: e.Name, Path: p, Kind: e.Kind, URL: pageURL(p), Link: o.Pick == "", Active: o.Pick == "" && p == o.Active, Expandable: e.HasChildren, } if e.Kind == "file" { row.URL = fileURL(strings.TrimPrefix(p, "/")) } if o.Pick != "" { row.Selectable = o.Pick == "any" || o.Pick == e.Kind row.Checked = row.Selectable && p == o.Select } if row.Expandable { row.ChildrenURL = row.URL + o.query() } return row } // listTreeEntries returns the immediate children of fsPath, filtering hidden // entries and index.md. Folders are listed before files; both groups are // sorted alphabetically. foldersOnly will not list any individual files at all. func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) { raw, err := os.ReadDir(fsPath) if err != nil { return nil, err } var folders, files []treeEntry for _, e := range raw { name := e.Name() if strings.HasPrefix(name, ".") { continue } if e.IsDir() { folders = append(folders, treeEntry{ Name: name, Kind: "folder", HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly), }) } else { if foldersOnly || name == "index.md" { continue } files = append(files, treeEntry{Name: name, Kind: "file"}) } } sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) return append(folders, files...), nil } // folderHasChildren reports whether fsPath contains at least one entry that // listTreeEntries would list. It reads the directory in small batches and // stops at the first match, so large folders stay cheap. Unreadable folders // report false. func folderHasChildren(fsPath string, foldersOnly bool) bool { f, err := os.Open(fsPath) if err != nil { return false } defer f.Close() for { batch, err := f.ReadDir(32) for _, e := range batch { name := e.Name() if strings.HasPrefix(name, ".") { continue } if e.IsDir() || (!foldersOnly && name != "index.md") { return true } } if err != nil { return false } } } // treePathSegments splits a wiki path into its non-empty segments. func treePathSegments(p string) []string { var segs []string for _, s := range strings.Split(p, "/") { if s != "" { segs = append(segs, s) } } return segs }