562 lines
14 KiB
Go
562 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func init() {
|
|
pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{})
|
|
}
|
|
|
|
type diaryHandler struct{}
|
|
|
|
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}
|
|
}
|
|
var content template.HTML
|
|
switch depth {
|
|
case 1:
|
|
content = renderDiaryYear(fsPath, urlPath)
|
|
case 2:
|
|
content = renderDiaryMonth(fsPath, urlPath)
|
|
case 3:
|
|
content = renderDiaryDay(fsPath, urlPath)
|
|
}
|
|
return &specialPage{Content: content, SuppressListing: true, Widget: widget}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
type calDay struct {
|
|
Num int
|
|
URL string
|
|
HasEntry bool
|
|
IsToday bool
|
|
IsCurrent bool
|
|
}
|
|
|
|
type calYear struct {
|
|
Num int
|
|
URL string
|
|
IsCurrent bool
|
|
}
|
|
|
|
type calMonth struct {
|
|
Num int
|
|
Name string
|
|
URL string
|
|
IsCurrent bool
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html"))
|
|
|
|
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
|
today := time.Now()
|
|
var displayYear, displayMonth, currentDay 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
|
|
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()))) + "/"
|
|
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
|
|
|
|
// 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 })
|
|
|
|
// 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,
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
|
type diaryPhoto struct {
|
|
Date time.Time
|
|
Name string
|
|
URL string
|
|
}
|
|
|
|
type diaryMonthSummary struct {
|
|
ID string
|
|
Name string
|
|
URL string
|
|
PhotoCount int
|
|
}
|
|
|
|
type diaryDaySection struct {
|
|
ID string
|
|
Heading string
|
|
URL string
|
|
EditURL string
|
|
Content template.HTML
|
|
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/diary-year.html"))
|
|
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html"))
|
|
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/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{
|
|
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",
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
photos = append(photos, diaryPhoto{
|
|
Date: t,
|
|
Name: name,
|
|
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
|
})
|
|
}
|
|
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))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
photos := yearPhotos(fsPath, urlPath)
|
|
|
|
entries, err := os.ReadDir(fsPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var months []diaryMonthSummary
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
monthNum, err := strconv.Atoi(e.Name())
|
|
if err != nil || monthNum < 1 || monthNum > 12 {
|
|
continue
|
|
}
|
|
count := 0
|
|
for _, p := range photos {
|
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
|
count++
|
|
}
|
|
}
|
|
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
|
months = append(months, diaryMonthSummary{
|
|
ID: monthDate.Format("2006-01"),
|
|
Name: monthDate.Format("January 2006"),
|
|
URL: path.Join(urlPath, e.Name()) + "/",
|
|
PhotoCount: count,
|
|
})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
|
|
log.Printf("diary year template: %v", err)
|
|
return ""
|
|
}
|
|
return template.HTML(buf.String())
|
|
}
|
|
|
|
// renderDiaryMonth renders a section per day, each with its markdown content
|
|
// and photos sourced from the parent year folder.
|
|
func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
|
yearFsPath := filepath.Dir(fsPath)
|
|
yearURLPath := parentURL(urlPath)
|
|
|
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
|
|
if err != nil || monthNum < 1 || monthNum > 12 {
|
|
return ""
|
|
}
|
|
|
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
|
var monthPhotos []diaryPhoto
|
|
for _, p := range allPhotos {
|
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
|
monthPhotos = append(monthPhotos, p)
|
|
}
|
|
}
|
|
|
|
// Collect day numbers from subdirectories and from photo filenames.
|
|
daySet := map[int]bool{}
|
|
dayDirs := map[int]string{} // day number → actual directory name
|
|
entries, _ := os.ReadDir(fsPath)
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
d, err := strconv.Atoi(e.Name())
|
|
if err != nil || d < 1 || d > 31 {
|
|
continue
|
|
}
|
|
daySet[d] = true
|
|
dayDirs[d] = e.Name()
|
|
}
|
|
for _, p := range monthPhotos {
|
|
daySet[p.Date.Day()] = true
|
|
}
|
|
|
|
days := make([]int, 0, len(daySet))
|
|
for d := range daySet {
|
|
days = append(days, d)
|
|
}
|
|
sort.Ints(days)
|
|
|
|
var sections []diaryDaySection
|
|
for _, dayNum := range days {
|
|
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
|
|
|
heading := date.Format("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)
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
|
|
log.Printf("diary month template: %v", err)
|
|
return ""
|
|
}
|
|
return template.HTML(buf.String())
|
|
}
|
|
|
|
// renderDiaryDay renders the photo grid for a single day, sourcing photos
|
|
// from the grandparent year folder.
|
|
func renderDiaryDay(fsPath, urlPath string) template.HTML {
|
|
monthFsPath := filepath.Dir(fsPath)
|
|
yearFsPath := filepath.Dir(monthFsPath)
|
|
yearURLPath := parentURL(parentURL(urlPath))
|
|
|
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
|
var photos []diaryPhoto
|
|
for _, p := range allPhotos {
|
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
|
|
photos = append(photos, p)
|
|
}
|
|
}
|
|
|
|
if len(photos) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
|
|
log.Printf("diary day template: %v", err)
|
|
return ""
|
|
}
|
|
return template.HTML(buf.String())
|
|
}
|