Files
datascape/tree.go
T
2026-06-16 15:03:18 +02:00

134 lines
3.9 KiB
Go

package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
type treeEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
}
type treeResponse struct {
Path string `json:"path"`
Entries []treeEntry `json:"entries"`
}
// handleTree responds with a JSON listing of the immediate children of the
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
// descended — the client lazy-loads children on expand.
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
}
entries, err := listTreeEntries(fsPath)
if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError)
return
}
// expandTo asks for a nested listing: each folder along the ancestor chain
// carries its own children, recursively, down to the target. Used by the
// tree sidebar to render the current page's chain in one request. The flat
// picker omits expandTo and is unaffected.
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo))
}
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
}
// canonicalTreePath returns the URL path in the form used by the picker:
// "/" for root, otherwise stripped of any trailing slash.
func canonicalTreePath(urlPath string) string {
if urlPath == "" || urlPath == "/" {
return "/"
}
return "/" + strings.Trim(urlPath, "/")
}
// listTreeEntries returns the immediate children of fsPath, filtering hidden
// entries and index.md. Folders are listed before files; both groups are
// sorted alphabetically.
func listTreeEntries(fsPath string) ([]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"})
} else {
if 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
}
// 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
}
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:])
return
}
}