diff --git a/CLAUDE.md b/CLAUDE.md index eaf667e..4cb52ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,8 @@ When building features, apply this order: ## Date Formatting - General UI dates (file listings, metadata): ISO `YYYY-MM-DD` -- Diary long-form dates: German locale, e.g. `Mittwoch, 1. April 2026` — use `formatGermanDate` in `diary.go`; Go's `time.Format` is English-only so locale names are kept in maps keyed by `time.Weekday` / `time.Month` +- Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering. +- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only. ## What to Avoid diff --git a/assets/diary/calendar.html b/assets/diary/calendar.html index 99e5201..f8912d9 100644 --- a/assets/diary/calendar.html +++ b/assets/diary/calendar.html @@ -1,11 +1,13 @@ -
+
- {{.MonthName}} + {{.DisplayMonthName}} {{.DisplayYear}} @@ -16,7 +18,8 @@
- + {{range .Months}} +
@@ -25,5 +28,6 @@ {{end}}
MoDiMiDoFrSaSo
+ {{end}} diff --git a/assets/diary/calendar.js b/assets/diary/calendar.js index d02048e..ffe1464 100644 --- a/assets/diary/calendar.js +++ b/assets/diary/calendar.js @@ -2,4 +2,51 @@ var cal = document.querySelector(".diary-cal"); if (!cal) return; cal.querySelectorAll(".dropdown > button").forEach(wireDropdown); + + var displayYear = parseInt(cal.dataset.displayYear, 10); + var current = parseInt(cal.dataset.displayMonth, 10); + var monthLink = cal.querySelector("[data-cal-month-link]"); + var months = {}; + cal.querySelectorAll("[data-cal-month]").forEach(function (t) { + months[parseInt(t.dataset.calMonth, 10)] = t; + }); + var jumpLinks = {}; + cal.querySelectorAll("[data-cal-month-jump]").forEach(function (a) { + jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a; + }); + + function pad(n) { return n < 10 ? "0" + n : "" + n; } + + function show(m) { + if (m === current) return; + if (!months[m]) return; + months[current].hidden = true; + months[m].hidden = false; + current = m; + if (monthLink) { + var label = jumpLinks[m]; + if (label) monthLink.textContent = label.textContent; + monthLink.setAttribute("href", "#" + displayYear + "-" + pad(m)); + } + } + + // Dropdown month picks scroll via the href anchor; we also swap the grid + // so the calendar reflects what the user just navigated to. + Object.keys(jumpLinks).forEach(function (key) { + var a = jumpLinks[key]; + a.addEventListener("click", function () { show(parseInt(key, 10)); }); + }); + + // Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar + // so it tracks the user's focus through the year page. + function syncFromHash() { + var h = window.location.hash; + if (!h) return; + var m = h.match(/^#(\d{4})-(\d{2})(?:-\d{2})?$/); + if (!m) return; + if (parseInt(m[1], 10) !== displayYear) return; + show(parseInt(m[2], 10)); + } + window.addEventListener("hashchange", syncFromHash); + syncFromHash(); })(); diff --git a/assets/diary/content.html b/assets/diary/content.html new file mode 100644 index 0000000..7d195d1 --- /dev/null +++ b/assets/diary/content.html @@ -0,0 +1,13 @@ +{{range .Sections}} +{{if eq .Level 1}}

{{.Heading}}{{if .EditURL}} edit{{end}}

+{{else if eq .Level 2}}

{{.Heading}}{{if .EditURL}} edit{{end}}

+{{else}}

{{.Heading}}{{if .EditURL}} edit{{end}}

{{end}} +{{if .Body}}{{.Body}}{{end}} +{{if .Photos}} +
+ {{range .Photos}} + + {{end}} +
+{{end}} +{{end}} diff --git a/assets/diary/day.html b/assets/diary/day.html deleted file mode 100644 index 42a5bd6..0000000 --- a/assets/diary/day.html +++ /dev/null @@ -1,7 +0,0 @@ -{{if .Photos}} -
- {{range .Photos}} - - {{end}} -
-{{end}} diff --git a/assets/diary/month.html b/assets/diary/month.html deleted file mode 100644 index 6f72ff6..0000000 --- a/assets/diary/month.html +++ /dev/null @@ -1,13 +0,0 @@ -{{range .Days}} -

{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}} - {{if .EditURL}}edit{{end}} -

-{{if .Content}}{{.Content}}{{end}} -{{if .Photos}} -
- {{range .Photos}} - - {{end}} -
-{{end}} -{{end}} diff --git a/assets/diary/year.html b/assets/diary/year.html deleted file mode 100644 index dd578b5..0000000 --- a/assets/diary/year.html +++ /dev/null @@ -1,11 +0,0 @@ -

Monate

-{{range .Months}} -

{{.Name}}

-{{if .Photos}} -
- {{range .Photos}} - - {{end}} -
-{{end}} -{{end}} diff --git a/assets/editor/main.html b/assets/editor/main.html index e787b0e..3bca954 100644 --- a/assets/editor/main.html +++ b/assets/editor/main.html @@ -9,6 +9,7 @@
{{if ge .SectionIndex 0}}{{end}} + {{if ge .InsertBefore 0}}{{end}}
diff --git a/assets/page/main.html b/assets/page/main.html index 1be9576..7d1ecb5 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -28,7 +28,7 @@ {{if or .Content .SpecialContent}} - +{{if not .SuppressTOC}}{{end}} {{end}} {{if .Content}} diff --git a/assets/page/sections.js b/assets/page/sections.js index cdab9e6..00342a8 100644 --- a/assets/page/sections.js +++ b/assets/page/sections.js @@ -6,12 +6,16 @@ // Section 0 is pre-heading content, editable via full-page edit. // Sections 1..N each start at a heading; that is the index sent to the server. + // Skip headings that already carry a server-rendered edit link (the diary + // slice templates bake their own edit URLs pointing at the year file). headings.forEach(function (h, i) { + if (h.querySelector('a.btn')) return; var a = document.createElement('a'); a.href = '?edit§ion=' + (i + 1); a.className = 'btn btn-small'; - a.textContent = 'edit'; + a.textContent = 'edit'; h.appendChild(document.createTextNode(' ')) h.appendChild(a); }); }()); +`` diff --git a/assets/style.css b/assets/style.css index a6ebf9d..ac3234a 100644 --- a/assets/style.css +++ b/assets/style.css @@ -325,7 +325,7 @@ main > h2 { .dropdown-menu.align-right { left: auto; right: 0; } .dropdown-menu.open-up { top: auto; bottom: 100%; margin-bottom: 0.4rem; } .dropdown-menu.is-open { display: block; } -.dropdown-menu.scrollable { max-height: 14rem; overflow-y: auto; } +.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; } /* === Suggestion dropdown (header search + editor link picker) === Anchored to a position:relative host. Mirrors .dropdown-menu visuals with @@ -449,7 +449,6 @@ button.fab { display: none; } top: var(--space-4); align-self: start; max-height: calc(100vh - 2rem); - overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-4); @@ -582,6 +581,12 @@ aside.sidebar:empty { display: none; } font-size: var(--font-sm); } .diary-cal-nav .diary-cal-drop + .diary-cal-heading { margin-left: var(--space-3); } +/* Anchor the month/year dropdowns to the nav row instead of the ▾ button so + the menu spans the full panel width rather than overflowing the 14rem + sidebar with its default min-width: 9rem. */ +.diary-cal-nav { position: relative; } +.diary-cal-nav .diary-cal-drop { position: static; } +.diary-cal-nav .dropdown-menu { left: 0; right: 0; min-width: 0; } .diary-cal-heading { color: var(--link); } .diary-cal-heading:hover { color: var(--link-hover); } .diary-cal-grid { diff --git a/diary.go b/diary.go index c88b5ef..92ff981 100644 --- a/diary.go +++ b/diary.go @@ -5,10 +5,12 @@ import ( "fmt" "html/template" "log" + "net/http" "net/url" "os" "path" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -21,9 +23,31 @@ func init() { type diaryHandler struct{} -// redirect resolves the persistent date links (today, this-month, this-year) -// inside any diary root to a dated URL. Returns ok=false otherwise. -func (d *diaryHandler) redirect(root, fsPath, urlPath string) (string, bool) { +// 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": @@ -42,44 +66,98 @@ func (d *diaryHandler) redirect(root, fsPath, urlPath string) (string, bool) { 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": - target := path.Join(diaryRootURL, year, month, day) + "/" - dayFS := filepath.Join(diaryRootFS, year, month, day) - if _, err := os.Stat(dayFS); err != nil { - target += "?edit" + dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day) + if dayHeadingExists(diaryRootFS, year, dayHeading) { + return yearURL + "#" + dayHeading, true } - return target, 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 path.Join(diaryRootURL, year, month) + "/", true + return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true case "this-year": - return path.Join(diaryRootURL, year) + "/", true + return yearURL, true } return "", false } -// defaultHeading returns the German long-form date as the editor pre-fill -// heading for a diary day folder (depth 3 inside a diary root). -func (d *diaryHandler) defaultHeading(root, fsPath, urlPath string) (string, bool) { - depth, _, _, ok := findDiaryContext(root, fsPath, urlPath) - if !ok || depth != 3 { +// 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 } - day, err := strconv.Atoi(filepath.Base(fsPath)) - if err != nil { + if info, err := os.Stat(fsPath); err == nil && info.IsDir() { return "", false } - monthFS := filepath.Dir(fsPath) - month, err := strconv.Atoi(filepath.Base(monthFS)) - if err != nil || month < 1 || month > 12 { + + year, month, day, ok := parseDiaryURLParts(fsPath, depth) + if !ok { return "", false } - year, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS))) - if err != nil { - 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 } - return formatGermanDate(time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)), 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 { @@ -89,18 +167,29 @@ func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { } widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth) if depth == 0 { - return &specialPage{Widget: widget} + return &specialPage{Widget: widget, SuppressTOC: true} } - var content template.HTML - switch depth { - case 1: - content = renderDiaryYear(fsPath, urlPath) - case 2: - content = renderDiaryMonth(fsPath, urlPath) - case 3: - content = renderDiaryDay(fsPath, urlPath) + year, _, _, ok := parseDiaryURLParts(fsPath, depth) + if !ok { + return &specialPage{Widget: widget, SuppressTOC: true} } - return &specialPage{Content: content, SuppressListing: true, Widget: widget} + 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 @@ -128,6 +217,130 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia 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 @@ -142,32 +355,32 @@ type calYear struct { IsCurrent bool } -type calMonth struct { +// 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 - URL string - IsCurrent bool + AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise + Weeks [][]calDay } type calendarData struct { - DisplayYear int - DisplayMonth int - MonthName string - DiaryURL string - YearURL string - MonthURL string - PrevMonURL string - NextMonURL string - Weeks [][]calDay - Years []calYear - AllMonths []calMonth + DisplayYear int + DisplayMonth int + DisplayMonthName string // pre-resolved so the template doesn't need arithmetic + DisplayMonthURL string + 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 int + var displayYear, displayMonth, currentDay, currentMonth int switch depth { case 0: @@ -212,73 +425,55 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) displayYear = y displayMonth = m currentDay = d + currentMonth = m default: return "" } - // Which days in the display month have diary subfolders? - monthFSPath := filepath.Join(diaryRootFS, - fmt.Sprintf("%d", displayYear), - fmt.Sprintf("%02d", displayMonth)) - dayEntries, _ := os.ReadDir(monthFSPath) - hasDayEntry := map[int]bool{} - for _, e := range dayEntries { - if !e.IsDir() { - continue - } - d, err := strconv.Atoi(e.Name()) - if err != nil || d < 1 || d > 31 { - continue - } - hasDayEntry[d] = true - } - - // Build calendar grid with Monday as first column. - firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC) - startOffset := int(firstDay.Weekday()+6) % 7 - daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day() - - monthURLBase := path.Join(diaryRootURL, - fmt.Sprintf("%d", displayYear), - fmt.Sprintf("%02d", displayMonth)) + "/" - - var weeks [][]calDay - week := make([]calDay, 7) - col := startOffset - for d := 1; d <= daysInMonth; d++ { - dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/" - cell := calDay{Num: d, HasEntry: hasDayEntry[d]} - if cell.HasEntry { - cell.URL = dayURL - } else { - cell.URL = dayURL + "?edit" - } - cell.IsCurrent = d == currentDay - cell.IsToday = d == today.Day() && - time.Month(displayMonth) == today.Month() && - displayYear == 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) - } - - prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC) - next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC) - prevMonURL := path.Join(diaryRootURL, - fmt.Sprintf("%d", prev.Year()), - fmt.Sprintf("%02d", int(prev.Month()))) + "/" - nextMonURL := path.Join(diaryRootURL, - fmt.Sprintf("%d", next.Year()), - fmt.Sprintf("%02d", int(next.Month()))) + "/" + 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], diaryRootURL, dayAnchor), + } + } + // Collect all year subdirectories in diary root (descending). yearEntries, _ := os.ReadDir(diaryRootFS) var years []calYear @@ -307,31 +502,15 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) } sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) - // All 12 months, ascending, linked to current display year. - allMonths := make([]calMonth, 12) - for m := 1; m <= 12; m++ { - allMonths[m-1] = calMonth{ - Num: m, - Name: germanMonths[time.Month(m)], - URL: path.Join(diaryRootURL, - fmt.Sprintf("%d", displayYear), - fmt.Sprintf("%02d", m)) + "/", - IsCurrent: m == displayMonth, - } - } - data := calendarData{ - DisplayYear: displayYear, - DisplayMonth: displayMonth, - MonthName: germanMonths[time.Month(displayMonth)], - DiaryURL: diaryRootURL, - YearURL: yearURL, - MonthURL: monthURLBase, - PrevMonURL: prevMonURL, - NextMonURL: nextMonURL, - Weeks: weeks, - Years: years, - AllMonths: allMonths, + DisplayYear: displayYear, + DisplayMonth: displayMonth, + DisplayMonthName: months[displayMonth-1].Name, + DisplayMonthURL: months[displayMonth-1].AnchorURL, + DiaryURL: diaryRootURL, + YearURL: yearURL, + Months: months, + Years: years, } var buf bytes.Buffer @@ -342,6 +521,48 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) 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 always link +// to the full month/day URL with ?edit so the diary handler can route into +// the "insert new day section" editor. +func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, diaryRootURL string, 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]} + if cell.HasEntry { + cell.URL = dayAnchor(year, month, d) + } else { + cell.URL = path.Join(diaryRootURL, + fmt.Sprintf("%d", year), + fmt.Sprintf("%02d", month), + fmt.Sprintf("%02d", d)) + "/?edit" + } + 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 @@ -350,41 +571,20 @@ type diaryPhoto struct { ThumbURL string } -type diaryMonthSummary struct { - ID string - Name string - URL string - Photos []diaryPhoto -} - -type diaryDaySection struct { - ID string - Heading string - URL string - EditURL string - Content template.HTML +// 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 diaryYearData struct { - Months []diaryMonthSummary - Year int -} -type diaryMonthData struct{ Days []diaryDaySection } -type diaryDayData struct{ Photos []diaryPhoto } - -var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/year.html")) -var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/month.html")) -var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/day.html")) - -var germanWeekdays = map[time.Weekday]string{ - time.Sunday: "Sonntag", - time.Monday: "Montag", - time.Tuesday: "Dienstag", - time.Wednesday: "Mittwoch", - time.Thursday: "Donnerstag", - time.Friday: "Freitag", - time.Saturday: "Samstag", +type diaryContentData struct { + Sections []diarySection } var germanMonths = map[time.Month]string{ @@ -402,15 +602,6 @@ var germanMonths = map[time.Month]string{ time.December: "Dezember", } -func formatGermanDate(t time.Time) string { - return fmt.Sprintf("%s, %d. %s %d", - germanWeekdays[t.Weekday()], - t.Day(), - germanMonths[t.Month()], - t.Year(), - ) -} - var photoExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, } @@ -453,196 +644,161 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto { return photos } -// renderDiaryYear renders month sections with photo counts for a year folder. -func renderDiaryYear(fsPath, urlPath string) template.HTML { - year, err := strconv.Atoi(filepath.Base(fsPath)) +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) - photos := yearPhotos(fsPath, urlPath) + out := buildSectionsForRange(sections, photos, 1, len(sections), yearURL) + out = appendOrphanPhotoDays(out, photos, year) + return renderDiaryContent(out) +} - // Collect month numbers from both subdirectories and photo filenames so - // years that contain only photos (no diary entries) still list months. - monthSet := map[int]bool{} - monthDirs := map[int]string{} - entries, _ := os.ReadDir(fsPath) - for _, e := range entries { - if !e.IsDir() { +// 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 } - n, err := strconv.Atoi(e.Name()) - if err != nil || n < 1 || n > 12 { - continue + sec := diarySection{ + Level: level, + ID: text, + Heading: text, + EditURL: fmt.Sprintf("%s?edit§ion=%d", yearURL, i), + Body: sectionBody(sections[i]), } - monthSet[n] = true - monthDirs[n] = e.Name() + if level == 3 { + if y, m, d, ok := parseISODate(text); ok { + sec.Photos = filterPhotos(photos, y, m, d) + } + } + out = append(out, sec) } + return out +} + +// appendOrphanPhotoDays inserts synthetic day sections (no edit button, no +// body) for photo dates not already covered by an explicit day section. +// Orphans are interleaved by date with existing *date* day sections only — +// non-date level-3 headings (e.g. `### Movies` in a year intro) keep their +// original document position. Remaining orphans are appended at the end so +// the year file's intro material always stays above the diary entries. +func appendOrphanPhotoDays(existing []diarySection, photos []diaryPhoto, year int) []diarySection { + covered := map[string]bool{} + for _, s := range existing { + if s.Level == 3 { + covered[s.ID] = true + } + } + type orphan struct { + date time.Time + header string + photos []diaryPhoto + } + orphMap := map[string]*orphan{} for _, p := range photos { - if p.Date.Year() == year { - monthSet[int(p.Date.Month())] = true + if p.Date.Year() != year { + continue } - } - - monthNums := make([]int, 0, len(monthSet)) - for m := range monthSet { - monthNums = append(monthNums, m) - } - sort.Ints(monthNums) - - var months []diaryMonthSummary - for _, monthNum := range monthNums { - var monthPhotos []diaryPhoto - for _, p := range photos { - if p.Date.Year() == year && int(p.Date.Month()) == monthNum { - monthPhotos = append(monthPhotos, p) - } + key := p.Date.Format("2006-01-02") + if covered[key] { + continue } - monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) - dirName, ok := monthDirs[monthNum] + o, ok := orphMap[key] if !ok { - dirName = fmt.Sprintf("%02d", monthNum) + o = &orphan{date: p.Date, header: key} + orphMap[key] = o } - months = append(months, diaryMonthSummary{ - ID: monthDate.Format("2006-01"), - Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year), - URL: path.Join(urlPath, dirName) + "/", - Photos: monthPhotos, - }) + o.photos = append(o.photos, p) } - - var buf bytes.Buffer - if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil { - log.Printf("diary year template: %v", err) - return "" + if len(orphMap) == 0 { + return existing } - 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 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 "" + orphans := make([]*orphan, 0, len(orphMap)) + for _, o := range orphMap { + orphans = append(orphans, o) } + sort.Slice(orphans, func(i, j int) bool { return orphans[i].date.Before(orphans[j].date) }) - 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("2006-01-02") - 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 { - raw = stripFirstHeading(raw) - content = renderMarkdown(raw) + out := make([]diarySection, 0, len(existing)+len(orphans)) + oi := 0 + for _, s := range existing { + if s.Level == 3 { + if _, _, _, ok := parseISODate(s.ID); ok { + for oi < len(orphans) && orphans[oi].header < s.ID { + out = append(out, diarySection{ + Level: 3, + ID: orphans[oi].header, + Heading: orphans[oi].header, + Photos: orphans[oi].photos, + }) + oi++ + } } } - - var photos []diaryPhoto - for _, p := range monthPhotos { - if p.Date.Day() == dayNum { - photos = append(photos, p) - } - } - - sections = append(sections, diaryDaySection{ - ID: date.Format("2006-01-02"), - Heading: heading, - URL: dayURL, - EditURL: dayURL + "?edit", - Content: content, - Photos: photos, + out = append(out, s) + } + for oi < len(orphans) { + out = append(out, diarySection{ + Level: 3, + ID: orphans[oi].header, + Heading: orphans[oi].header, + Photos: orphans[oi].photos, }) + oi++ } - - 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()) + return out } -// renderDiaryDay renders the photo grid for a single day, sourcing photos -// from the grandparent year folder. -func renderDiaryDay(fsPath, urlPath string) template.HTML { - monthFsPath := filepath.Dir(fsPath) - yearFsPath := filepath.Dir(monthFsPath) - yearURLPath := parentURL(parentURL(urlPath)) +// 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 +} - 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) +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 +} - if len(photos) == 0 { - return "" - } - +func renderDiaryContent(sections []diarySection) template.HTML { var buf bytes.Buffer - if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil { - log.Printf("diary day template: %v", err) + if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil { + log.Printf("diary content template: %v", err) return "" } return template.HTML(buf.String()) diff --git a/main.go b/main.go index d738020..1abe002 100644 --- a/main.go +++ b/main.go @@ -27,29 +27,28 @@ var ( // specialPage is the result returned by a pageTypeHandler. // Content is injected into the page after the standard markdown content. +// SuppressContent hides the markdown-rendered content (handler owns rendering). // SuppressListing hides the default file/folder listing. // Widget is a persistent sidebar widget rendered outside the main content area. type specialPage struct { Content template.HTML + SuppressContent bool SuppressListing bool + SuppressTOC bool Widget template.HTML } // pageTypeHandler is implemented by each special folder type (diary, gallery, …). // handle returns nil when the handler does not apply to the given path. // redirect returns ok=true with an absolute URL when the request should be -// short-circuited with a 302 redirect (e.g. persistent date links in a diary). -// defaultHeading returns ok=true with custom pre-fill heading text for the -// editor when no index.md exists yet (e.g. German long-form date for a diary -// day). Handlers that don't need a hook should return the zero value. +// short-circuited with a 302 redirect (e.g. persistent date links in a diary, +// or virtual diary URLs in edit mode that delegate to the year file's editor). // // When adding a new hook, prefer a sibling method here over folding logic -// into main.go or render.go. If this list grows much beyond three, consider -// collapsing into a single overrides struct returned per request. +// into main.go or render.go. type pageTypeHandler interface { handle(root, fsPath, urlPath string) *specialPage - redirect(root, fsPath, urlPath string) (target string, ok bool) - defaultHeading(root, fsPath, urlPath string) (heading string, ok bool) + redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool) } // pageTypeHandlers is the registry. Each type registers itself via init(). @@ -218,7 +217,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa } for _, ph := range pageTypeHandlers { - if target, ok := ph.redirect(h.root, fsPath, urlPath); ok { + if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { http.Redirect(w, r, target, http.StatusFound) return } @@ -229,17 +228,18 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa // Determine section index (-1 = whole page). sectionIndex := -1 + insertBefore := -1 if editMode { if s := r.URL.Query().Get("section"); s != "" { if n, err := strconv.Atoi(s); err == nil && n >= 0 { sectionIndex = n } } - } - - var rendered template.HTML - if len(rawMD) > 0 && !editMode { - rendered = renderMarkdown(rawMD) + if s := r.URL.Query().Get("insert_before"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n >= 0 { + insertBefore = n + } + } } var special *specialPage @@ -251,6 +251,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa } } + var rendered template.HTML + if len(rawMD) > 0 && !editMode && (special == nil || !special.SuppressContent) { + rendered = renderMarkdown(rawMD) + } + var entries []entry if !editMode && (special == nil || !special.SuppressListing) { entries = listEntries(fsPath, urlPath) @@ -263,26 +268,32 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa var specialContent template.HTML var sidebarWidget template.HTML + suppressTOC := false if special != nil { specialContent = special.Content sidebarWidget = special.Widget + suppressTOC = special.SuppressTOC } rawContent := string(rawMD) - if editMode && sectionIndex >= 0 { + if editMode && insertBefore >= 0 { + heading := r.URL.Query().Get("heading") + level := r.URL.Query().Get("level") + if level == "" { + level = "###" + } + if heading != "" { + rawContent = level + " " + heading + "\n\n" + } else { + rawContent = "" + } + } else if editMode && sectionIndex >= 0 { sections := splitSections(rawMD) if sectionIndex < len(sections) { rawContent = string(sections[sectionIndex]) } } else if editMode && rawContent == "" && urlPath != "/" { - heading := pageTitle(urlPath) - for _, ph := range pageTypeHandlers { - if custom, ok := ph.defaultHeading(h.root, fsPath, urlPath); ok { - heading = custom - break - } - } - rawContent = "# " + heading + "\n\n" + rawContent = "# " + pageTitle(urlPath) + "\n\n" } parent := "" @@ -296,12 +307,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa EditMode: editMode, IsRoot: urlPath == "/", SectionIndex: sectionIndex, + InsertBefore: insertBefore, PostURL: urlPath, RawContent: rawContent, Content: rendered, Entries: entries, SpecialContent: specialContent, SidebarWidget: sidebarWidget, + SuppressTOC: suppressTOC, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -350,9 +363,33 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs indexPath := filepath.Join(fsPath, "index.md") redirectTarget := urlPath - // If a section index was submitted, splice the edited section back into - // the full file rather than replacing the whole document. - if s := r.FormValue("section"); s != "" { + // insert_before splices a new section into the file *at* index N rather + // than replacing index N (used by the diary "create new day" flow). + // section replaces the section at index N (used by per-section edits). + // Exactly one of insert_before / section should be set; insert_before + // wins if both are present. + if s := r.FormValue("insert_before"); s != "" { + insertIndex, err := strconv.Atoi(s) + if err != nil || insertIndex < 0 { + http.Error(w, "bad insert_before", http.StatusBadRequest) + return + } + rawMD, _ := os.ReadFile(indexPath) + sections := splitSections(rawMD) + if insertIndex > len(sections) { + insertIndex = len(sections) + } + newSection := []byte(content) + inserted := make([][]byte, 0, len(sections)+1) + inserted = append(inserted, sections[:insertIndex]...) + inserted = append(inserted, newSection) + inserted = append(inserted, sections[insertIndex:]...) + content = string(joinSections(inserted)) + ids := headingIDs([]byte(content)) + if insertIndex-1 >= 0 && insertIndex-1 < len(ids) { + redirectTarget = urlPath + "#" + ids[insertIndex-1] + } + } else if s := r.FormValue("section"); s != "" { sectionIndex, err := strconv.Atoi(s) if err != nil || sectionIndex < 0 { http.Error(w, "bad section", http.StatusBadRequest) diff --git a/render.go b/render.go index ca791fd..df08015 100644 --- a/render.go +++ b/render.go @@ -40,12 +40,14 @@ type pageData struct { EditMode bool IsRoot bool SectionIndex int // -1 = whole page; >=0 = section being edited + InsertBefore int // -1 = no insert; >=0 = splice new section at this index PostURL string RawContent string Content template.HTML Entries []entry SpecialContent template.HTML SidebarWidget template.HTML + SuppressTOC bool RenderMS int64 }