package main import ( "bytes" "fmt" "html/template" "log" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "sort" "strconv" "strings" "time" ) func init() { pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{}) } type diaryHandler struct{} // redirect handles diary-specific redirect cases. The year page is the only // real diary page; month and day URLs are aliases that collapse to a year // page anchor (or to the year-file editor when ?edit is set). // // 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a // year+anchor target (or insert flow when today's section is missing). // 2. A virtual month URL (/diary//YYYY/MM/) redirects to // /diary//YYYY/#YYYY-MM (or to ?edit§ion=N when ?edit is set). // 3. A virtual day URL (/diary//YYYY/MM/DD/) redirects to // /diary//YYYY/#YYYY-MM-DD (or to the section / insert_before // editor flow when ?edit is set). // // Returns ok=false when the request is not a diary-handled redirect. func (d *diaryHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) { if target, ok := d.dateShortcutRedirect(root, fsPath, urlPath); ok { return target, true } if r.Method != http.MethodGet { return "", false } _, edit := r.URL.Query()["edit"] return d.virtualURLRedirect(root, fsPath, urlPath, edit) } func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (string, bool) { base := path.Base(strings.TrimSuffix(urlPath, "/")) switch base { case "today", "this-month", "this-year": default: return "", false } parentFS := filepath.Dir(fsPath) parentURLPath := parentURL(urlPath) _, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath) if !ok { return "", false } now := time.Now() year := fmt.Sprintf("%d", now.Year()) month := fmt.Sprintf("%02d", int(now.Month())) day := fmt.Sprintf("%02d", now.Day()) yearURL := path.Join(diaryRootURL, year) + "/" switch base { case "today": dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day) if dayHeadingExists(diaryRootFS, year, dayHeading) { return yearURL + "#" + dayHeading, true } // Missing day: route through the insert flow so today's section // is spliced in at the right chronological position. yearFS := filepath.Join(diaryRootFS, year) raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md")) sections := splitSections(raw) insertIdx := computeInsertIndex(sections, dayHeading) return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s", yearURL, insertIdx, url.QueryEscape(dayHeading)), true case "this-month": return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true case "this-year": return yearURL, true } return "", false } // virtualURLRedirect collapses month/day URLs onto the year page. For // non-edit GETs the target is `/YYYY/#YYYY-MM[-DD]`. For ?edit GETs the // target is the year-file editor URL (section edit when the section exists, // otherwise insert_before+heading for new days, whole-year edit for new // months). Returns ok=false when fsPath is a real folder (preferring the // real folder over the virtual redirect lets users recover from an // unfinished migration). func (d *diaryHandler) virtualURLRedirect(root, fsPath, urlPath string, edit bool) (string, bool) { depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath) if !ok || (depth != 2 && depth != 3) { return "", false } if info, err := os.Stat(fsPath); err == nil && info.IsDir() { return "", false } year, month, day, ok := parseDiaryURLParts(fsPath, depth) if !ok { return "", false } yearFS := filepath.Join(diaryRootFS, year) yearURL := path.Join(diaryRootURL, year) + "/" if !edit { anchor := fmt.Sprintf("%s-%s", year, month) if depth == 3 { anchor = fmt.Sprintf("%s-%s-%s", year, month, day) } return yearURL + "#" + anchor, true } raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md")) sections := splitSections(raw) if depth == 2 { target := fmt.Sprintf("%s-%s", year, month) if idx, found := findSectionIndex(sections, target); found { return fmt.Sprintf("%s?edit§ion=%d", yearURL, idx), true } return yearURL + "?edit", true } target := fmt.Sprintf("%s-%s-%s", year, month, day) if idx, found := findSectionIndex(sections, target); found { return fmt.Sprintf("%s?edit§ion=%d", yearURL, idx), true } insertIdx := computeInsertIndex(sections, target) return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s", yearURL, insertIdx, url.QueryEscape(target)), true } // parseDiaryURLParts extracts year/month/day from fsPath based on depth. // depth=1 returns year only; depth=2 returns year+month; depth=3 returns all. func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok bool) { parts := []string{} cur := fsPath for i := 0; i < depth; i++ { parts = append([]string{filepath.Base(cur)}, parts...) cur = filepath.Dir(cur) } switch depth { case 1: return parts[0], "", "", true case 2: return parts[0], parts[1], "", true case 3: return parts[0], parts[1], parts[2], true } return "", "", "", false } func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath) if !ok { return nil } widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth) if depth == 0 { return &specialPage{Widget: widget, SuppressTOC: true} } year, _, _, ok := parseDiaryURLParts(fsPath, depth) if !ok { return &specialPage{Widget: widget, SuppressTOC: true} } if depth == 1 { yearFS := filepath.Join(diaryRootFS, year) yearURL := path.Join(diaryRootURL, year) + "/" content := renderDiaryYear(yearFS, yearURL) return &specialPage{ Content: content, SuppressContent: true, SuppressListing: true, SuppressTOC: true, Widget: widget, } } // depth 2/3 only reach here when a real folder exists at the path // (unfinished migration). The virtual URL would have been redirected // in `redirect()` otherwise. Render the folder normally; just add the // calendar widget. return &specialPage{Widget: widget, SuppressTOC: true} } // findDiaryContext walks up from fsPath toward root looking for a // .page-settings file with type=diary. Returns the depth of fsPath // relative to the diary root, the diary root fs path, its URL, and // whether a diary root was found. depth=0 means fsPath itself is the root. func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, diaryRootURL string, ok bool) { currentFS := fsPath currentURL := urlPath for d := 0; ; d++ { s := readPageSettings(currentFS) if s != nil && s.Type == "diary" { return d, currentFS, currentURL, true } if currentFS == root { break } parent := filepath.Dir(currentFS) if parent == currentFS { break } currentFS = parent currentURL = parentURL(currentURL) } return 0, "", "", false } // headingTextRe matches an ATX heading at the start of a section. The // heading text is everything after the `#`s and the required space, on the // first line. var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`) // sectionHeading returns the heading level (1..6) and trimmed text of a // section produced by splitSections. Returns level=0 for the pre-heading // section (index 0). func sectionHeading(section []byte) (level int, text string) { m := headingTextRe.FindSubmatch(section) if m == nil { return 0, "" } return len(m[1]), strings.TrimSpace(string(m[2])) } // findSectionIndex returns the absolute section index whose heading text // matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match. func findSectionIndex(sections [][]byte, target string) (int, bool) { for i := 1; i < len(sections); i++ { _, text := sectionHeading(sections[i]) if text == target { return i, true } } return 0, false } // computeInsertIndex returns the index at which a new day section with the // given target date heading (YYYY-MM-DD) should be spliced in to keep date // sections chronologically ordered. Only date-format headings — `YYYY`, // `YYYY-MM`, or `YYYY-MM-DD` — participate in the comparison; non-date // headings (e.g. `### Movies` in a year intro) are skipped so the new day // is placed relative to the surrounding date sections, not the intro. // Falls back to len(sections) when target is greater than every date // heading. String comparison works for ISO dates. func computeInsertIndex(sections [][]byte, target string) int { for i := 1; i < len(sections); i++ { _, text := sectionHeading(sections[i]) if !isDateHeading(text) { continue } if text > target { return i } } return len(sections) } // isDateHeading reports whether text is exactly a `YYYY`, `YYYY-MM`, or // `YYYY-MM-DD` token. Used by the insert-index search to ignore non-date // section headings. func isDateHeading(text string) bool { switch len(text) { case 4, 7, 10: default: return false } if _, err := time.Parse("2006", text[:4]); err != nil { return false } if len(text) >= 7 { if text[4] != '-' { return false } if _, err := time.Parse("2006-01", text[:7]); err != nil { return false } } if len(text) == 10 { if text[7] != '-' { return false } if _, err := time.Parse("2006-01-02", text); err != nil { return false } } return true } // dayHeadingExists reads the year file and reports whether a `### date` // section exists with the given heading text (e.g. "2026-05-28"). func dayHeadingExists(diaryRootFS, year, dateText string) bool { raw, err := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md")) if err != nil { return false } sections := splitSections(raw) _, ok := findSectionIndex(sections, dateText) return ok } // daysWithEntriesByMonth returns a `month → set[day]` map of `### YYYY-MM-DD` // sections in the year's index.md. Used by the calendar widget to populate // all 12 month grids in a single file read. func daysWithEntriesByMonth(yearFS string, year int) map[int]map[int]bool { out := map[int]map[int]bool{} raw, err := os.ReadFile(filepath.Join(yearFS, "index.md")) if err != nil { return out } yearPrefix := fmt.Sprintf("%d-", year) sections := splitSections(raw) for i := 1; i < len(sections); i++ { level, text := sectionHeading(sections[i]) if level != 3 || !strings.HasPrefix(text, yearPrefix) || len(text) < 10 { continue } m, err := strconv.Atoi(text[5:7]) if err != nil || m < 1 || m > 12 { continue } d, err := strconv.Atoi(text[8:10]) if err != nil || d < 1 || d > 31 { continue } if out[m] == nil { out[m] = map[int]bool{} } out[m][d] = true } return out } type calDay struct { Num int URL string HasEntry bool IsToday bool IsCurrent bool } type calYear struct { Num int URL string IsCurrent bool } // calMonthGrid carries everything the template needs to render one month's // grid plus the dropdown / heading entry that targets it. The calendar // widget ships all 12 in the initial HTML; JS swaps which one is visible. type calMonthGrid struct { Num int Name string AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise Weeks [][]calDay } type calendarData struct { DisplayYear int DisplayMonth int DisplayMonthName string // pre-resolved so the template doesn't need arithmetic DiaryURL string YearURL string Months []calMonthGrid Years []calYear } var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html")) func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { today := time.Now() var displayYear, displayMonth, currentDay, currentMonth int switch depth { case 0: displayYear = today.Year() displayMonth = int(today.Month()) case 1: y, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil { return "" } displayYear = y if y == today.Year() { displayMonth = int(today.Month()) } else { displayMonth = 1 } case 2: m, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || m < 1 || m > 12 { return "" } y, err := strconv.Atoi(filepath.Base(filepath.Dir(fsPath))) if err != nil { return "" } displayYear = y displayMonth = m case 3: d, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || d < 1 || d > 31 { return "" } monthFS := filepath.Dir(fsPath) m, err := strconv.Atoi(filepath.Base(monthFS)) if err != nil || m < 1 || m > 12 { return "" } y, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS))) if err != nil { return "" } displayYear = y displayMonth = m currentDay = d currentMonth = m default: return "" } yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear)) hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear) yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/" // On a year page, in-year month/day links collapse to anchors so the // browser scrolls within the current page instead of navigating away. // On the diary root (depth=0), all links remain full URLs. pageYear := 0 if depth >= 1 { pageYear = displayYear } monthAnchor := func(year, month int) string { if pageYear == year { return fmt.Sprintf("#%d-%02d", year, month) } return path.Join(diaryRootURL, fmt.Sprintf("%d", year), fmt.Sprintf("%02d", month)) + "/" } dayAnchor := func(year, month, day int) string { if pageYear == year { return fmt.Sprintf("#%d-%02d-%02d", year, month, day) } return path.Join(diaryRootURL, fmt.Sprintf("%d", year), fmt.Sprintf("%02d", month), fmt.Sprintf("%02d", day)) + "/" } months := make([]calMonthGrid, 12) for m := 1; m <= 12; m++ { var cd int if m == currentMonth { cd = currentDay } months[m-1] = calMonthGrid{ Num: m, Name: germanMonths[time.Month(m)], AnchorURL: monthAnchor(displayYear, m), Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor), } } // Collect all year subdirectories in diary root (descending). yearEntries, _ := os.ReadDir(diaryRootFS) var years []calYear yearSet := map[int]bool{} for _, e := range yearEntries { if !e.IsDir() { continue } y, err := strconv.Atoi(e.Name()) if err != nil { continue } yearSet[y] = true years = append(years, calYear{ Num: y, URL: path.Join(diaryRootURL, e.Name()) + "/", IsCurrent: y == displayYear, }) } if !yearSet[displayYear] { years = append(years, calYear{ Num: displayYear, URL: yearURL, IsCurrent: true, }) } sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) data := calendarData{ DisplayYear: displayYear, DisplayMonth: displayMonth, DisplayMonthName: months[displayMonth-1].Name, DiaryURL: diaryRootURL, YearURL: yearURL, Months: months, Years: years, } var buf bytes.Buffer if err := diaryCalTmpl.Execute(&buf, data); err != nil { log.Printf("diary calendar template: %v", err) return "" } return template.HTML(buf.String()) } // buildMonthGrid renders one month's day cells as a Monday-first week grid. // hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the // in-page anchor (or full URL when crossing pages); empty days link to the // same anchor — every day exists on the year page as either a real or // virtual section, so navigation is enough. Page creation happens via the // [edit] button on the heading itself. func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay { firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) startOffset := int(firstDay.Weekday()+6) % 7 daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day() var weeks [][]calDay week := make([]calDay, 7) col := startOffset for d := 1; d <= daysInMonth; d++ { cell := calDay{ Num: d, HasEntry: hasDayEntry[d], URL: dayAnchor(year, month, d), } cell.IsCurrent = currentDay > 0 && d == currentDay cell.IsToday = d == today.Day() && time.Month(month) == today.Month() && year == today.Year() week[col] = cell col++ if col == 7 { weeks = append(weeks, week) week = make([]calDay, 7) col = 0 } } if col > 0 { weeks = append(weeks, week) } return weeks } // 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 ThumbURL string } // diarySection is one rendered section of the diary content (year, month, or // day). Edit URLs point back into the year file's section editor so per-day // editing works from any slice page. type diarySection struct { Level int // 1, 2, or 3 ID string // anchor id (e.g. "2026-05-28") Heading string // displayed heading text EditURL string // year-file section edit URL ("" = no edit button) Body template.HTML // rendered markdown body (excludes the heading line) Photos []diaryPhoto } type diaryContentData struct { Sections []diarySection } var germanMonths = map[time.Month]string{ time.January: "Januar", time.February: "Februar", time.March: "März", time.April: "April", time.May: "Mai", time.June: "Juni", time.July: "Juli", time.August: "August", time.September: "September", time.October: "Oktober", time.November: "November", time.December: "Dezember", } var photoExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, } // yearPhotos returns all photos in yearFsPath whose filename starts with // a YYYY-MM-DD date prefix. 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 } photoURL := path.Join(yearURLPath, url.PathEscape(name)) thumb := photoURL if hasThumbnail(name) { thumb = thumbURL(photoURL, 300) } photos = append(photos, diaryPhoto{ Date: t, Name: name, URL: photoURL, ThumbURL: thumb, }) } return photos } var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html")) // sectionBody strips the first heading line and returns the rendered body. // Used so the diary template can emit the heading explicitly (with edit URL) // while still rendering the section body via goldmark. func sectionBody(section []byte) template.HTML { body := stripFirstHeading(section) if len(bytes.TrimSpace(body)) == 0 { return "" } return renderMarkdown(body) } // renderDiaryYear renders the full year file with photos attached to each // `### YYYY-MM-DD` section. func renderDiaryYear(yearFS, yearURL string) template.HTML { year, err := strconv.Atoi(filepath.Base(yearFS)) if err != nil { return "" } raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md")) sections := splitSections(raw) photos := yearPhotos(yearFS, yearURL) out := buildSectionsForRange(sections, photos, 1, len(sections), yearURL) out = appendVirtualEntries(out, sections, photos, year, yearURL) return renderDiaryContent(out) } // buildSectionsForRange converts raw splitSections entries in [start, end) // into rendered diarySection entries, attaching photos to day sections. func buildSectionsForRange(sections [][]byte, photos []diaryPhoto, start, end int, yearURL string) []diarySection { var out []diarySection for i := start; i < end; i++ { level, text := sectionHeading(sections[i]) if level == 0 { continue } sec := diarySection{ Level: level, ID: text, Heading: text, EditURL: fmt.Sprintf("%s?edit§ion=%d", yearURL, i), Body: sectionBody(sections[i]), } if level == 3 { if y, m, d, ok := parseISODate(text); ok { sec.Photos = filterPhotos(photos, y, m, d) } } out = append(out, sec) } return out } // appendVirtualEntries inserts virtual month and day sections for every // `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section. // Virtual day sections carry photos when present. Each virtual entry's // EditURL routes through the insert-before flow so clicking [edit] splices // the section into the year file at the right chronological position. // // Scope: past years get all 12 months / 365(6) days; the current year stops // at today; future years are returned unchanged. // // Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their // document position; virtual entries are spliced in lexicographic ID order // before the next real date section. Non-date headings (e.g. `## Events` → // `### Festival` in a year intro) are left where the user wrote them. func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection { today := time.Now() if year > today.Year() { return existing } coveredMonth := map[string]bool{} coveredDay := map[string]bool{} for _, s := range existing { switch { case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID): coveredMonth[s.ID] = true case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID): coveredDay[s.ID] = true } } photoByDay := map[string][]diaryPhoto{} for _, p := range photos { if p.Date.Year() != year { continue } photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p) } lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC) if year == today.Year() { lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) } var virtual []diarySection for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) { if d.Day() == 1 { monthID := d.Format("2006-01") if !coveredMonth[monthID] { idx := computeInsertIndex(sections, monthID) virtual = append(virtual, diarySection{ Level: 2, ID: monthID, Heading: monthID, EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s", yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")), }) } } dayID := d.Format("2006-01-02") if !coveredDay[dayID] { idx := computeInsertIndex(sections, dayID) virtual = append(virtual, diarySection{ Level: 3, ID: dayID, Heading: dayID, EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s", yearURL, idx, url.QueryEscape(dayID)), Photos: photoByDay[dayID], }) } } if len(virtual) == 0 { return existing } out := make([]diarySection, 0, len(existing)+len(virtual)) vi := 0 for _, s := range existing { if isRealDateSection(s) { for vi < len(virtual) && virtual[vi].ID < s.ID { out = append(out, virtual[vi]) vi++ } } out = append(out, s) } for vi < len(virtual) { out = append(out, virtual[vi]) vi++ } return out } // isRealDateSection reports whether a rendered diarySection is one of the // date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry // interleave sorts against. func isRealDateSection(s diarySection) bool { switch s.Level { case 2: return len(s.ID) == 7 && isDateHeading(s.ID) case 3: return len(s.ID) == 10 && isDateHeading(s.ID) } return false } // parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false // if the prefix does not match. func parseISODate(s string) (year, month, day int, ok bool) { if len(s) < 10 { return 0, 0, 0, false } t, err := time.Parse("2006-01-02", s[:10]) if err != nil { return 0, 0, 0, false } return t.Year(), int(t.Month()), t.Day(), true } func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto { var out []diaryPhoto for _, p := range photos { if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day { out = append(out, p) } } return out } func renderDiaryContent(sections []diarySection) template.HTML { var buf bytes.Buffer if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil { log.Printf("diary content template: %v", err) return "" } return template.HTML(buf.String()) }