diff --git a/assets/page.html b/assets/page.html index 3a3c15d..a1dc96a 100644 --- a/assets/page.html +++ b/assets/page.html @@ -45,10 +45,17 @@ - {{else}} {{if .Content}} + {{else}} + {{if .Content}}
{{.Content}}
+ {{end}} + {{if .DiaryContent}} +
{{.DiaryContent}}
+ {{end}} + {{if or .Content .DiaryContent}} - {{end}} {{if .Entries}} + {{end}} + {{if .Entries}}
Contents
{{range .Entries}} @@ -60,8 +67,11 @@ {{end}}
{{else if not .Content}} + {{if not .DiaryContent}}

Empty folder — [CREATE]

- {{end}} {{end}} + {{end}} + {{end}} + {{end}} diff --git a/assets/style.css b/assets/style.css index 2f0b80e..85aba9f 100644 --- a/assets/style.css +++ b/assets/style.css @@ -345,6 +345,54 @@ textarea:focus { color: #ffb300; } +/* === Diary views === */ +.diary-section { + margin: 2rem 0; + padding-top: 1.5rem; + border-top: 1px dashed #0a0; +} + +.diary-section:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; +} + +.diary-heading { + font-size: 1.2rem; + color: white; + margin-bottom: 0.75rem; + font-weight: normal; +} + +.diary-photo-count { + color: #888; + font-size: 0.85rem; +} + +.diary-photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.4rem; + margin-top: 0.75rem; +} + +.diary-photo-grid a { + display: block; + line-height: 0; +} + +.diary-photo-grid img { + width: 100%; + height: 140px; + object-fit: cover; + display: block; +} + +.diary-section .content { + margin-bottom: 0.75rem; +} + /* === Empty state === */ .empty { padding: 1rem; diff --git a/main.go b/main.go index c3b6f25..71fc2e3 100644 --- a/main.go +++ b/main.go @@ -9,11 +9,14 @@ import ( "io/fs" "log" "net/http" + "net/url" "os" "path" "path/filepath" "sort" + "strconv" "strings" + "time" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" @@ -32,23 +35,66 @@ var md = goldmark.New( var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html")) +// Diary sub-templates — executed into a buffer and injected as DiaryContent. +var diaryYearTmpl = template.Must(template.New("diary-year").Parse( + `{{range .Months}}

{{.Name}}{{if .PhotoCount}} ({{.PhotoCount}} photos){{end}}

{{end}}`, +)) + +var diaryMonthTmpl = template.Must(template.New("diary-month").Parse( + `{{range .Days}}

{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}}

{{if .Content}}
{{.Content}}
{{end}}{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}
{{end}}`, +)) + +var diaryDayTmpl = template.Must(template.New("diary-day").Parse( + `{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}`, +)) + type crumb struct{ Name, URL string } type entry struct { - Icon template.HTML + Icon template.HTML Name, URL, Meta string } type pageData struct { - Title string - Crumbs []crumb - CanEdit bool - EditMode bool - PostURL string - RawContent string - Content template.HTML - Entries []entry + Title string + Crumbs []crumb + CanEdit bool + EditMode bool + PostURL string + RawContent string + Content template.HTML + Entries []entry + DiaryContent template.HTML } +// pageSettings holds the parsed contents of a .page-settings file. +type pageSettings struct { + Type string +} + +// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. +type diaryPhoto struct { + Date time.Time + Name string + URL string +} + +type diaryMonthSummary struct { + Name string + URL string + PhotoCount int +} + +type diaryDaySection struct { + Heading string + URL string + Content template.HTML + Photos []diaryPhoto +} + +type diaryYearData struct{ Months []diaryMonthSummary } +type diaryMonthData struct{ Days []diaryDaySection } +type diaryDayData struct{ Photos []diaryPhoto } + func main() { addr := flag.String("addr", ":8080", "listen address") wikiDir := flag.String("dir", "./wiki", "wiki root directory") @@ -90,7 +136,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { urlPath := path.Clean("/" + r.URL.Path) -fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath)) + fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath)) // Security: ensure the resolved path stays within root. rel, err := filepath.Rel(h.root, fsPath) @@ -101,6 +147,10 @@ fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath)) info, err := os.Stat(fsPath) if err != nil { + if os.IsNotExist(err) && strings.HasSuffix(r.URL.Path, "/") { + h.serveDir(w, r, urlPath, fsPath) + return + } http.NotFound(w, r) return } @@ -128,26 +178,44 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa var rendered template.HTML if len(rawMD) > 0 && !editMode { - var buf bytes.Buffer - if err := md.Convert(rawMD, &buf); err == nil { - rendered = template.HTML(buf.String()) - } + rendered = renderMarkdown(rawMD) } + diaryDepth, isDiary := h.findDiaryContext(fsPath) + + // For diary sub-pages (depth > 0) the diary content replaces the file listing. var entries []entry - if !editMode { + if !editMode && (!isDiary || diaryDepth == 0) { entries = listEntries(fsPath, urlPath) } + var diaryContent template.HTML + if !editMode && isDiary { + switch diaryDepth { + case 1: + diaryContent = h.renderDiaryYear(fsPath, urlPath) + case 2: + diaryContent = h.renderDiaryMonth(fsPath, urlPath) + case 3: + diaryContent = h.renderDiaryDay(fsPath, urlPath) + } + } + + title := pageTitle(urlPath) + if h := extractFirstHeading(rawMD); h != "" { + title = h + } + data := pageData{ - Title: pageTitle(urlPath), - Crumbs: buildCrumbs(urlPath), - CanEdit: true, - EditMode: editMode, - PostURL: urlPath, - RawContent: string(rawMD), - Content: rendered, - Entries: entries, + Title: title, + Crumbs: buildCrumbs(urlPath), + CanEdit: true, + EditMode: editMode, + PostURL: urlPath, + RawContent: string(rawMD), + Content: rendered, + Entries: entries, + DiaryContent: diaryContent, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -169,6 +237,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs return } } else { + if err := os.MkdirAll(fsPath, 0755); err != nil { + http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) + return + } if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil { http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) return @@ -177,6 +249,313 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs http.Redirect(w, r, urlPath, http.StatusSeeOther) } +// readPageSettings parses a .page-settings file in dir. +// Returns nil if the file does not exist. +// Format: one "key = value" pair per line; lines starting with # are comments. +func readPageSettings(dir string) *pageSettings { + data, err := os.ReadFile(filepath.Join(dir, ".page-settings")) + if err != nil { + return nil + } + s := &pageSettings{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + switch strings.TrimSpace(parts[0]) { + case "type": + s.Type = strings.TrimSpace(parts[1]) + } + } + return s +} + +// findDiaryContext walks up from fsPath toward h.root looking for a +// .page-settings file with type=diary. Returns the depth of fsPath +// relative to the diary root, and whether a diary root was found at all. +// depth=0 means fsPath itself is the diary root. +func (h *handler) findDiaryContext(fsPath string) (int, bool) { + current := fsPath + for depth := 0; ; depth++ { + s := readPageSettings(current) + if s != nil && s.Type == "diary" { + return depth, true + } + if current == h.root { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return 0, false +} + +// yearPhotos returns all photos in yearFsPath whose filename starts with +// a YYYY-MM-DD date prefix. yearURLPath is the corresponding URL prefix. +var photoExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, +} + +func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto { + entries, err := os.ReadDir(yearFsPath) + if err != nil { + return nil + } + var photos []diaryPhoto + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !photoExts[strings.ToLower(filepath.Ext(name))] { + continue + } + if len(name) < 10 { + continue + } + t, err := time.Parse("2006-01-02", name[:10]) + if err != nil { + continue + } + photos = append(photos, diaryPhoto{ + Date: t, + Name: name, + URL: path.Join(yearURLPath, url.PathEscape(name)), + }) + } + return photos +} + +// extractFirstHeading returns the text of the first ATX heading in raw markdown, +// or an empty string if none is found. +func extractFirstHeading(raw []byte) string { + for _, line := range strings.SplitN(string(raw), "\n", 50) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#") { + continue + } + text := strings.TrimSpace(strings.TrimLeft(trimmed, "#")) + if text != "" { + return text + } + } + return "" +} + +// stripFirstHeading removes the first ATX heading line from raw markdown. +func stripFirstHeading(raw []byte) []byte { + lines := strings.Split(string(raw), "\n") + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + result := append(lines[:i:i], lines[i+1:]...) + return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n")) + } + } + return raw +} + +// renderMarkdown converts raw markdown to trusted HTML. +func renderMarkdown(raw []byte) template.HTML { + var buf bytes.Buffer + if err := md.Convert(raw, &buf); err != nil { + return "" + } + return template.HTML(buf.String()) +} + +// parentURL returns the parent URL of a slash-terminated URL path. +func parentURL(urlPath string) string { + parent := path.Dir(strings.TrimSuffix(urlPath, "/")) + if parent == "." || parent == "/" { + return "/" + } + return parent + "/" +} + +// renderDiaryYear renders month sections with photo counts for a year folder. +func (h *handler) renderDiaryYear(fsPath, urlPath string) template.HTML { + yearStr := filepath.Base(fsPath) + year, err := strconv.Atoi(yearStr) + if err != nil { + return "" + } + + photos := yearPhotos(fsPath, urlPath) + + entries, err := os.ReadDir(fsPath) + if err != nil { + return "" + } + + var months []diaryMonthSummary + for _, e := range entries { + if !e.IsDir() { + continue + } + monthNum, err := strconv.Atoi(e.Name()) + if err != nil || monthNum < 1 || monthNum > 12 { + continue + } + count := 0 + for _, p := range photos { + if p.Date.Year() == year && int(p.Date.Month()) == monthNum { + count++ + } + } + monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) + months = append(months, diaryMonthSummary{ + Name: monthDate.Format("January 2006"), + URL: path.Join(urlPath, e.Name()) + "/", + PhotoCount: count, + }) + } + + var buf bytes.Buffer + if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil { + log.Printf("diary year template: %v", err) + return "" + } + return template.HTML(buf.String()) +} + +// renderDiaryMonth renders a section per day, each with its markdown content +// and photos sourced from the parent year folder. +func (h *handler) renderDiaryMonth(fsPath, urlPath string) template.HTML { + yearFsPath := filepath.Dir(fsPath) + yearURLPath := parentURL(urlPath) + + year, err := strconv.Atoi(filepath.Base(yearFsPath)) + if err != nil { + return "" + } + monthNum, err := strconv.Atoi(filepath.Base(fsPath)) + if err != nil || monthNum < 1 || monthNum > 12 { + return "" + } + + allPhotos := yearPhotos(yearFsPath, yearURLPath) + var monthPhotos []diaryPhoto + for _, p := range allPhotos { + if p.Date.Year() == year && int(p.Date.Month()) == monthNum { + monthPhotos = append(monthPhotos, p) + } + } + + // Collect day numbers from subdirectories and from photo filenames. + daySet := map[int]bool{} + dayDirs := map[int]string{} // day number → actual directory name + entries, _ := os.ReadDir(fsPath) + for _, e := range entries { + if !e.IsDir() { + continue + } + d, err := strconv.Atoi(e.Name()) + if err != nil || d < 1 || d > 31 { + continue + } + daySet[d] = true + dayDirs[d] = e.Name() + } + for _, p := range monthPhotos { + daySet[p.Date.Day()] = true + } + + days := make([]int, 0, len(daySet)) + for d := range daySet { + days = append(days, d) + } + sort.Ints(days) + + var sections []diaryDaySection + for _, dayNum := range days { + date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC) + + heading := date.Format("Monday, January 2") + dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/" + var content template.HTML + if dirName, ok := dayDirs[dayNum]; ok { + dayURL = path.Join(urlPath, dirName) + "/" + dayFsPath := filepath.Join(fsPath, dirName) + if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 { + if h := extractFirstHeading(raw); h != "" { + heading = h + raw = stripFirstHeading(raw) + } + content = renderMarkdown(raw) + } + } + + var photos []diaryPhoto + for _, p := range monthPhotos { + if p.Date.Day() == dayNum { + photos = append(photos, p) + } + } + + sections = append(sections, diaryDaySection{ + Heading: heading, + URL: dayURL, + Content: content, + Photos: photos, + }) + } + + var buf bytes.Buffer + if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil { + log.Printf("diary month template: %v", err) + return "" + } + return template.HTML(buf.String()) +} + +// renderDiaryDay renders the photo grid for a single day, sourcing photos +// from the grandparent year folder. +func (h *handler) renderDiaryDay(fsPath, urlPath string) template.HTML { + monthFsPath := filepath.Dir(fsPath) + yearFsPath := filepath.Dir(monthFsPath) + yearURLPath := parentURL(parentURL(urlPath)) + + year, err := strconv.Atoi(filepath.Base(yearFsPath)) + if err != nil { + return "" + } + monthNum, err := strconv.Atoi(filepath.Base(monthFsPath)) + if err != nil { + return "" + } + dayNum, err := strconv.Atoi(filepath.Base(fsPath)) + if err != nil { + return "" + } + + allPhotos := yearPhotos(yearFsPath, yearURLPath) + var photos []diaryPhoto + for _, p := range allPhotos { + if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum { + photos = append(photos, p) + } + } + + if len(photos) == 0 { + return "" + } + + var buf bytes.Buffer + if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil { + log.Printf("diary day template: %v", err) + return "" + } + return template.HTML(buf.String()) +} + func listEntries(fsPath, urlPath string) []entry { entries, err := os.ReadDir(fsPath) if err != nil {