Rework diary to focus on yearly pages

This commit is contained in:
2026-05-28 13:54:44 +02:00
parent 20a6bac3d6
commit 61e50c033f
14 changed files with 641 additions and 402 deletions
+2 -1
View File
@@ -88,7 +88,8 @@ When building features, apply this order:
## Date Formatting ## Date Formatting
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD` - 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 ## What to Avoid
+8 -4
View File
@@ -1,11 +1,13 @@
<div class="diary-cal panel panel-sidebar"> <div class="diary-cal panel panel-sidebar"
data-display-year="{{.DisplayYear}}"
data-display-month="{{.DisplayMonth}}">
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div> <div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
<div class="diary-cal-nav"> <div class="diary-cal-nav">
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a> <a href="{{.DisplayMonthURL}}" class="diary-cal-heading" data-cal-month-link>{{.DisplayMonthName}}</a>
<div class="dropdown diary-cal-drop"> <div class="dropdown diary-cal-drop">
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"></button> <button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"></button>
<div class="dropdown-menu scrollable"> <div class="dropdown-menu scrollable">
{{range .AllMonths}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}} {{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
</div> </div>
</div> </div>
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a> <a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
@@ -16,7 +18,8 @@
</div> </div>
</div> </div>
</div> </div>
<table class="diary-cal-grid"> {{range .Months}}
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
<thead> <thead>
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr> <tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
</thead> </thead>
@@ -25,5 +28,6 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{end}}
</div> </div>
<script src="/_/diary/calendar.js"></script> <script src="/_/diary/calendar.js"></script>
+47
View File
@@ -2,4 +2,51 @@
var cal = document.querySelector(".diary-cal"); var cal = document.querySelector(".diary-cal");
if (!cal) return; if (!cal) return;
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown); 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();
})(); })();
+13
View File
@@ -0,0 +1,13 @@
{{range .Sections}}
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h3>{{end}}
{{if .Body}}{{.Body}}{{end}}
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-7
View File
@@ -1,7 +0,0 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
-13
View File
@@ -1,13 +0,0 @@
{{range .Days}}
<h2 id="{{.ID}}">{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
</h2>
{{if .Content}}{{.Content}}{{end}}
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-11
View File
@@ -1,11 +0,0 @@
<h2 id="months">Monate</h2>
{{range .Months}}
<h3 id="{{.ID}}"><a href="{{.URL}}">{{.Name}}</a></h3>
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
+1
View File
@@ -9,6 +9,7 @@
</script> </script>
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}"> <form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}} {{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button> <button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button> <button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
+1 -1
View File
@@ -28,7 +28,7 @@
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script> <script src="/_/page/content.js"></script>
<script src="/_/page/anchors.js"></script> <script src="/_/page/anchors.js"></script>
<script src="/_/page/toc.js"></script> {{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script> <script src="/_/page/tasks.js"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
+5 -1
View File
@@ -6,12 +6,16 @@
// Section 0 is pre-heading content, editable via full-page edit. // 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. // 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) { headings.forEach(function (h, i) {
if (h.querySelector('a.btn')) return;
var a = document.createElement('a'); var a = document.createElement('a');
a.href = '?edit&section=' + (i + 1); a.href = '?edit&section=' + (i + 1);
a.className = 'btn btn-small'; a.className = 'btn btn-small';
a.textContent = 'edit'; a.textContent = 'edit';
h.appendChild(document.createTextNode(' ')) h.appendChild(document.createTextNode(' '))
h.appendChild(a); h.appendChild(a);
}); });
}()); }());
``
+7 -2
View File
@@ -325,7 +325,7 @@ main > h2 {
.dropdown-menu.align-right { left: auto; right: 0; } .dropdown-menu.align-right { left: auto; right: 0; }
.dropdown-menu.open-up { top: auto; bottom: 100%; margin-bottom: 0.4rem; } .dropdown-menu.open-up { top: auto; bottom: 100%; margin-bottom: 0.4rem; }
.dropdown-menu.is-open { display: block; } .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) === /* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -449,7 +449,6 @@ button.fab { display: none; }
top: var(--space-4); top: var(--space-4);
align-self: start; align-self: start;
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
@@ -582,6 +581,12 @@ aside.sidebar:empty { display: none; }
font-size: var(--font-sm); font-size: var(--font-sm);
} }
.diary-cal-nav .diary-cal-drop + .diary-cal-heading { margin-left: var(--space-3); } .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 { color: var(--link); }
.diary-cal-heading:hover { color: var(--link-hover); } .diary-cal-heading:hover { color: var(--link-hover); }
.diary-cal-grid { .diary-cal-grid {
+492 -336
View File
@@ -5,10 +5,12 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net/http"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -21,9 +23,31 @@ func init() {
type diaryHandler struct{} type diaryHandler struct{}
// redirect resolves the persistent date links (today, this-month, this-year) // redirect handles diary-specific redirect cases. The year page is the only
// inside any diary root to a dated URL. Returns ok=false otherwise. // real diary page; month and day URLs are aliases that collapse to a year
func (d *diaryHandler) redirect(root, fsPath, urlPath string) (string, bool) { // 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/<root>/YYYY/MM/) redirects to
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
// /diary/<root>/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, "/")) base := path.Base(strings.TrimSuffix(urlPath, "/"))
switch base { switch base {
case "today", "this-month", "this-year": 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()) year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", int(now.Month())) month := fmt.Sprintf("%02d", int(now.Month()))
day := fmt.Sprintf("%02d", now.Day()) day := fmt.Sprintf("%02d", now.Day())
yearURL := path.Join(diaryRootURL, year) + "/"
switch base { switch base {
case "today": case "today":
target := path.Join(diaryRootURL, year, month, day) + "/" dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
dayFS := filepath.Join(diaryRootFS, year, month, day) if dayHeadingExists(diaryRootFS, year, dayHeading) {
if _, err := os.Stat(dayFS); err != nil { return yearURL + "#" + dayHeading, true
target += "?edit"
} }
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": case "this-month":
return path.Join(diaryRootURL, year, month) + "/", true return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
case "this-year": case "this-year":
return path.Join(diaryRootURL, year) + "/", true return yearURL, true
} }
return "", false return "", false
} }
// defaultHeading returns the German long-form date as the editor pre-fill // virtualURLRedirect collapses month/day URLs onto the year page. For
// heading for a diary day folder (depth 3 inside a diary root). // non-edit GETs the target is `/YYYY/#YYYY-MM[-DD]`. For ?edit GETs the
func (d *diaryHandler) defaultHeading(root, fsPath, urlPath string) (string, bool) { // target is the year-file editor URL (section edit when the section exists,
depth, _, _, ok := findDiaryContext(root, fsPath, urlPath) // otherwise insert_before+heading for new days, whole-year edit for new
if !ok || depth != 3 { // 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 return "", false
} }
day, err := strconv.Atoi(filepath.Base(fsPath)) if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
if err != nil {
return "", false return "", false
} }
monthFS := filepath.Dir(fsPath)
month, err := strconv.Atoi(filepath.Base(monthFS)) year, month, day, ok := parseDiaryURLParts(fsPath, depth)
if err != nil || month < 1 || month > 12 { if !ok {
return "", false return "", false
} }
year, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS))) yearFS := filepath.Join(diaryRootFS, year)
if err != nil { yearURL := path.Join(diaryRootURL, year) + "/"
return "", false
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&section=%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&section=%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 { 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) widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
if depth == 0 { if depth == 0 {
return &specialPage{Widget: widget} return &specialPage{Widget: widget, SuppressTOC: true}
} }
var content template.HTML year, _, _, ok := parseDiaryURLParts(fsPath, depth)
switch depth { if !ok {
case 1: return &specialPage{Widget: widget, SuppressTOC: true}
content = renderDiaryYear(fsPath, urlPath)
case 2:
content = renderDiaryMonth(fsPath, urlPath)
case 3:
content = renderDiaryDay(fsPath, urlPath)
} }
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 // 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 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 { type calDay struct {
Num int Num int
URL string URL string
@@ -142,32 +355,32 @@ type calYear struct {
IsCurrent bool 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 Num int
Name string Name string
URL string AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
IsCurrent bool Weeks [][]calDay
} }
type calendarData struct { type calendarData struct {
DisplayYear int DisplayYear int
DisplayMonth int DisplayMonth int
MonthName string DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
DiaryURL string DisplayMonthURL string
YearURL string DiaryURL string
MonthURL string YearURL string
PrevMonURL string Months []calMonthGrid
NextMonURL string Years []calYear
Weeks [][]calDay
Years []calYear
AllMonths []calMonth
} }
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html")) var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now() today := time.Now()
var displayYear, displayMonth, currentDay int var displayYear, displayMonth, currentDay, currentMonth int
switch depth { switch depth {
case 0: case 0:
@@ -212,73 +425,55 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
displayYear = y displayYear = y
displayMonth = m displayMonth = m
currentDay = d currentDay = d
currentMonth = m
default: default:
return "" return ""
} }
// Which days in the display month have diary subfolders? yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
monthFSPath := filepath.Join(diaryRootFS, hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear)
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()))) + "/"
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", 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). // Collect all year subdirectories in diary root (descending).
yearEntries, _ := os.ReadDir(diaryRootFS) yearEntries, _ := os.ReadDir(diaryRootFS)
var years []calYear 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 }) 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{ data := calendarData{
DisplayYear: displayYear, DisplayYear: displayYear,
DisplayMonth: displayMonth, DisplayMonth: displayMonth,
MonthName: germanMonths[time.Month(displayMonth)], DisplayMonthName: months[displayMonth-1].Name,
DiaryURL: diaryRootURL, DisplayMonthURL: months[displayMonth-1].AnchorURL,
YearURL: yearURL, DiaryURL: diaryRootURL,
MonthURL: monthURLBase, YearURL: yearURL,
PrevMonURL: prevMonURL, Months: months,
NextMonURL: nextMonURL, Years: years,
Weeks: weeks,
Years: years,
AllMonths: allMonths,
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -342,6 +521,48 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
return template.HTML(buf.String()) 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. // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct { type diaryPhoto struct {
Date time.Time Date time.Time
@@ -350,41 +571,20 @@ type diaryPhoto struct {
ThumbURL string ThumbURL string
} }
type diaryMonthSummary struct { // diarySection is one rendered section of the diary content (year, month, or
ID string // day). Edit URLs point back into the year file's section editor so per-day
Name string // editing works from any slice page.
URL string type diarySection struct {
Photos []diaryPhoto Level int // 1, 2, or 3
} ID string // anchor id (e.g. "2026-05-28")
Heading string // displayed heading text
type diaryDaySection struct { EditURL string // year-file section edit URL ("" = no edit button)
ID string Body template.HTML // rendered markdown body (excludes the heading line)
Heading string
URL string
EditURL string
Content template.HTML
Photos []diaryPhoto Photos []diaryPhoto
} }
type diaryYearData struct { type diaryContentData struct {
Months []diaryMonthSummary Sections []diarySection
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",
} }
var germanMonths = map[time.Month]string{ var germanMonths = map[time.Month]string{
@@ -402,15 +602,6 @@ var germanMonths = map[time.Month]string{
time.December: "Dezember", 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{ var photoExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
} }
@@ -453,196 +644,161 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
return photos return photos
} }
// renderDiaryYear renders month sections with photo counts for a year folder. var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))
func renderDiaryYear(fsPath, urlPath string) template.HTML {
year, err := strconv.Atoi(filepath.Base(fsPath)) // 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 { if err != nil {
return "" 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 // buildSectionsForRange converts raw splitSections entries in [start, end)
// years that contain only photos (no diary entries) still list months. // into rendered diarySection entries, attaching photos to day sections.
monthSet := map[int]bool{} func buildSectionsForRange(sections [][]byte, photos []diaryPhoto, start, end int, yearURL string) []diarySection {
monthDirs := map[int]string{} var out []diarySection
entries, _ := os.ReadDir(fsPath) for i := start; i < end; i++ {
for _, e := range entries { level, text := sectionHeading(sections[i])
if !e.IsDir() { if level == 0 {
continue continue
} }
n, err := strconv.Atoi(e.Name()) sec := diarySection{
if err != nil || n < 1 || n > 12 { Level: level,
continue ID: text,
Heading: text,
EditURL: fmt.Sprintf("%s?edit&section=%d", yearURL, i),
Body: sectionBody(sections[i]),
} }
monthSet[n] = true if level == 3 {
monthDirs[n] = e.Name() 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 { for _, p := range photos {
if p.Date.Year() == year { if p.Date.Year() != year {
monthSet[int(p.Date.Month())] = true continue
} }
} key := p.Date.Format("2006-01-02")
if covered[key] {
monthNums := make([]int, 0, len(monthSet)) continue
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)
}
} }
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) o, ok := orphMap[key]
dirName, ok := monthDirs[monthNum]
if !ok { if !ok {
dirName = fmt.Sprintf("%02d", monthNum) o = &orphan{date: p.Date, header: key}
orphMap[key] = o
} }
months = append(months, diaryMonthSummary{ o.photos = append(o.photos, p)
ID: monthDate.Format("2006-01"),
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
URL: path.Join(urlPath, dirName) + "/",
Photos: monthPhotos,
})
} }
if len(orphMap) == 0 {
var buf bytes.Buffer return existing
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
log.Printf("diary year template: %v", err)
return ""
} }
return template.HTML(buf.String()) orphans := make([]*orphan, 0, len(orphMap))
} for _, o := range orphMap {
orphans = append(orphans, o)
// 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 ""
} }
sort.Slice(orphans, func(i, j int) bool { return orphans[i].date.Before(orphans[j].date) })
allPhotos := yearPhotos(yearFsPath, yearURLPath) out := make([]diarySection, 0, len(existing)+len(orphans))
var monthPhotos []diaryPhoto oi := 0
for _, p := range allPhotos { for _, s := range existing {
if p.Date.Year() == year && int(p.Date.Month()) == monthNum { if s.Level == 3 {
monthPhotos = append(monthPhotos, p) if _, _, _, ok := parseISODate(s.ID); ok {
} for oi < len(orphans) && orphans[oi].header < s.ID {
} out = append(out, diarySection{
Level: 3,
// Collect day numbers from subdirectories and from photo filenames. ID: orphans[oi].header,
daySet := map[int]bool{} Heading: orphans[oi].header,
dayDirs := map[int]string{} // day number → actual directory name Photos: orphans[oi].photos,
entries, _ := os.ReadDir(fsPath) })
for _, e := range entries { oi++
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 = append(out, s)
var photos []diaryPhoto }
for _, p := range monthPhotos { for oi < len(orphans) {
if p.Date.Day() == dayNum { out = append(out, diarySection{
photos = append(photos, p) Level: 3,
} ID: orphans[oi].header,
} Heading: orphans[oi].header,
Photos: orphans[oi].photos,
sections = append(sections, diaryDaySection{
ID: date.Format("2006-01-02"),
Heading: heading,
URL: dayURL,
EditURL: dayURL + "?edit",
Content: content,
Photos: photos,
}) })
oi++
} }
return out
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 // parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
// from the grandparent year folder. // if the prefix does not match.
func renderDiaryDay(fsPath, urlPath string) template.HTML { func parseISODate(s string) (year, month, day int, ok bool) {
monthFsPath := filepath.Dir(fsPath) if len(s) < 10 {
yearFsPath := filepath.Dir(monthFsPath) return 0, 0, 0, false
yearURLPath := parentURL(parentURL(urlPath)) }
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)) func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto {
if err != nil { var out []diaryPhoto
return "" for _, p := range photos {
} if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath)) out = append(out, p)
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)
} }
} }
return out
}
if len(photos) == 0 { func renderDiaryContent(sections []diarySection) template.HTML {
return ""
}
var buf bytes.Buffer var buf bytes.Buffer
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil { if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
log.Printf("diary day template: %v", err) log.Printf("diary content template: %v", err)
return "" return ""
} }
return template.HTML(buf.String()) return template.HTML(buf.String())
+63 -26
View File
@@ -27,29 +27,28 @@ var (
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content. // 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. // SuppressListing hides the default file/folder listing.
// Widget is a persistent sidebar widget rendered outside the main content area. // Widget is a persistent sidebar widget rendered outside the main content area.
type specialPage struct { type specialPage struct {
Content template.HTML Content template.HTML
SuppressContent bool
SuppressListing bool SuppressListing bool
SuppressTOC bool
Widget template.HTML Widget template.HTML
} }
// pageTypeHandler is implemented by each special folder type (diary, gallery, …). // pageTypeHandler is implemented by each special folder type (diary, gallery, …).
// handle returns nil when the handler does not apply to the given path. // 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 // 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). // 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 // or virtual diary URLs in edit mode that delegate to the year file's editor).
// 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.
// //
// When adding a new hook, prefer a sibling method here over folding logic // 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 // into main.go or render.go.
// collapsing into a single overrides struct returned per request.
type pageTypeHandler interface { type pageTypeHandler interface {
handle(root, fsPath, urlPath string) *specialPage handle(root, fsPath, urlPath string) *specialPage
redirect(root, fsPath, urlPath string) (target string, ok bool) redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
defaultHeading(root, fsPath, urlPath string) (heading string, ok bool)
} }
// pageTypeHandlers is the registry. Each type registers itself via init(). // 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 { 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) http.Redirect(w, r, target, http.StatusFound)
return return
} }
@@ -229,17 +228,18 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
// Determine section index (-1 = whole page). // Determine section index (-1 = whole page).
sectionIndex := -1 sectionIndex := -1
insertBefore := -1
if editMode { if editMode {
if s := r.URL.Query().Get("section"); s != "" { if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 { if n, err := strconv.Atoi(s); err == nil && n >= 0 {
sectionIndex = n sectionIndex = n
} }
} }
} if s := r.URL.Query().Get("insert_before"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
var rendered template.HTML insertBefore = n
if len(rawMD) > 0 && !editMode { }
rendered = renderMarkdown(rawMD) }
} }
var special *specialPage 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 var entries []entry
if !editMode && (special == nil || !special.SuppressListing) { if !editMode && (special == nil || !special.SuppressListing) {
entries = listEntries(fsPath, urlPath) 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 specialContent template.HTML
var sidebarWidget template.HTML var sidebarWidget template.HTML
suppressTOC := false
if special != nil { if special != nil {
specialContent = special.Content specialContent = special.Content
sidebarWidget = special.Widget sidebarWidget = special.Widget
suppressTOC = special.SuppressTOC
} }
rawContent := string(rawMD) 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) sections := splitSections(rawMD)
if sectionIndex < len(sections) { if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex]) rawContent = string(sections[sectionIndex])
} }
} else if editMode && rawContent == "" && urlPath != "/" { } else if editMode && rawContent == "" && urlPath != "/" {
heading := pageTitle(urlPath) rawContent = "# " + pageTitle(urlPath) + "\n\n"
for _, ph := range pageTypeHandlers {
if custom, ok := ph.defaultHeading(h.root, fsPath, urlPath); ok {
heading = custom
break
}
}
rawContent = "# " + heading + "\n\n"
} }
parent := "" parent := ""
@@ -296,12 +307,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
EditMode: editMode, EditMode: editMode,
IsRoot: urlPath == "/", IsRoot: urlPath == "/",
SectionIndex: sectionIndex, SectionIndex: sectionIndex,
InsertBefore: insertBefore,
PostURL: urlPath, PostURL: urlPath,
RawContent: rawContent, RawContent: rawContent,
Content: rendered, Content: rendered,
Entries: entries, Entries: entries,
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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") indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath redirectTarget := urlPath
// If a section index was submitted, splice the edited section back into // insert_before splices a new section into the file *at* index N rather
// the full file rather than replacing the whole document. // than replacing index N (used by the diary "create new day" flow).
if s := r.FormValue("section"); s != "" { // 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) sectionIndex, err := strconv.Atoi(s)
if err != nil || sectionIndex < 0 { if err != nil || sectionIndex < 0 {
http.Error(w, "bad section", http.StatusBadRequest) http.Error(w, "bad section", http.StatusBadRequest)
+2
View File
@@ -40,12 +40,14 @@ type pageData struct {
EditMode bool EditMode bool
IsRoot bool IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited SectionIndex int // -1 = whole page; >=0 = section being edited
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
PostURL string PostURL string
RawContent string RawContent string
Content template.HTML Content template.HTML
Entries []entry Entries []entry
SpecialContent template.HTML SpecialContent template.HTML
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64 RenderMS int64
} }