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