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
}