Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5844a870ce | |||
| 51bf489449 | |||
| 61e50c033f |
@@ -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
|
||||
|
||||
|
||||
@@ -52,36 +52,45 @@ type = diary
|
||||
|
||||
### Diary
|
||||
|
||||
Designed for a chronological photo diary. Expected structure:
|
||||
Designed for a chronological photo diary. The whole year lives in a single
|
||||
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
|
||||
|
||||
```
|
||||
FolderName/
|
||||
.page-settings ← type = diary
|
||||
YYYY/
|
||||
YYYY-MM-DD Desc.jpg ← photos named with date prefix
|
||||
MM/
|
||||
DD/
|
||||
index.md ← diary entry for that day
|
||||
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
|
||||
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
|
||||
```
|
||||
|
||||
| View | What renders |
|
||||
|------|-------------|
|
||||
| Year (`YYYY/`) | Section per month with link and photo count |
|
||||
| Month (`MM/`) | Section per day with entry content and photo grid |
|
||||
| Day (`DD/`) | Entry content and photo grid |
|
||||
The year page (`YYYY/`) renders every section in the file with photos
|
||||
attached to each `### YYYY-MM-DD` heading. Months and days the file doesn't
|
||||
yet contain are rendered as **virtual** headings with an `[edit]` button that
|
||||
splices a new section into the year file at the right chronological position;
|
||||
virtual day headings still carry photos for that date. Past years render
|
||||
every month/day slot; the current year stops at today; future years skip
|
||||
virtual entries entirely. The file may contain non-date headings (e.g.
|
||||
`## Events` → `### Festival` between `# YYYY` and `## YYYY-01`); these keep
|
||||
their document position.
|
||||
|
||||
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
|
||||
A sidebar calendar widget shows one month grid at a time; the month-name
|
||||
button opens a dropdown of all twelve months, and a separate year dropdown
|
||||
jumps between years. Day cells link to the matching anchor on the year page
|
||||
regardless of whether the date has a real section yet.
|
||||
|
||||
#### Persistent date links
|
||||
|
||||
Each diary root exposes three stable paths intended for browser bookmarks. They redirect to the current dated URL on every visit:
|
||||
Each diary root exposes three stable paths intended for browser bookmarks.
|
||||
They resolve against the year page rather than separate per-day URLs:
|
||||
|
||||
| Path | Redirects to |
|
||||
|------|-------------|
|
||||
| `<diary>/today/` | `<diary>/YYYY/MM/DD/` (or `…/?edit` if the day folder does not exist yet) |
|
||||
| `<diary>/this-month/` | `<diary>/YYYY/MM/` |
|
||||
| `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) |
|
||||
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
|
||||
| `<diary>/this-year/` | `<diary>/YYYY/` |
|
||||
|
||||
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
|
||||
|
||||
## Quick-Add Bookmarklet
|
||||
|
||||
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<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="diary-cal-nav">
|
||||
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
|
||||
<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" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button>
|
||||
<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>
|
||||
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
|
||||
<div class="dropdown diary-cal-drop">
|
||||
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
||||
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
||||
<div class="dropdown-menu align-right scrollable">
|
||||
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||
</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>
|
||||
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
||||
</thead>
|
||||
@@ -25,5 +27,6 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
<script src="/_/diary/calendar.js"></script>
|
||||
|
||||
@@ -2,4 +2,48 @@
|
||||
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 monthLabel = 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 show(m) {
|
||||
if (m === current) return;
|
||||
if (!months[m]) return;
|
||||
months[current].hidden = true;
|
||||
months[m].hidden = false;
|
||||
current = m;
|
||||
if (monthLabel) {
|
||||
var label = jumpLinks[m];
|
||||
if (label) monthLabel.textContent = " " + label.textContent + " ";
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
})();
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -9,6 +9,7 @@
|
||||
</script>
|
||||
<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 .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||
<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="italic" data-key="I" title="Italic (I)">*</button>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/page/content.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>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
// 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.
|
||||
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';
|
||||
|
||||
+7
-2
@@ -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 {
|
||||
|
||||
@@ -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/<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":
|
||||
@@ -42,44 +66,97 @@ 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)
|
||||
raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md"))
|
||||
sections := splitSections(raw)
|
||||
if _, found := findSectionIndex(sections, dayHeading); found {
|
||||
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.
|
||||
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 formatGermanDate(time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)), true
|
||||
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 {
|
||||
@@ -89,18 +166,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 +216,118 @@ 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 section index at which a new date heading
|
||||
// (target = `YYYY-MM` or `YYYY-MM-DD`) should be spliced in to keep date
|
||||
// sections chronologically ordered. Only date-format headings participate in
|
||||
// the comparison; non-date headings (e.g. `## Events` in a year intro) are
|
||||
// skipped so the new section is placed relative to the surrounding date
|
||||
// sections, not the intro. Falls back to len(sections) when target is
|
||||
// greater than every date heading. ISO formatting means string comparison
|
||||
// is equivalent to chronological order.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 +342,31 @@ 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
|
||||
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
|
||||
DiaryURL string
|
||||
YearURL string
|
||||
MonthURL string
|
||||
PrevMonURL string
|
||||
NextMonURL string
|
||||
Weeks [][]calDay
|
||||
Months []calMonthGrid
|
||||
Years []calYear
|
||||
AllMonths []calMonth
|
||||
}
|
||||
|
||||
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 +411,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], dayAnchor),
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all year subdirectories in diary root (descending).
|
||||
yearEntries, _ := os.ReadDir(diaryRootFS)
|
||||
var years []calYear
|
||||
@@ -307,31 +488,14 @@ 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)],
|
||||
DisplayMonthName: months[displayMonth-1].Name,
|
||||
DiaryURL: diaryRootURL,
|
||||
YearURL: yearURL,
|
||||
MonthURL: monthURLBase,
|
||||
PrevMonURL: prevMonURL,
|
||||
NextMonURL: nextMonURL,
|
||||
Weeks: weeks,
|
||||
Months: months,
|
||||
Years: years,
|
||||
AllMonths: allMonths,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -342,6 +506,45 @@ 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 link to the
|
||||
// same anchor — every day exists on the year page as either a real or
|
||||
// virtual section, so navigation is enough. Page creation happens via the
|
||||
// [edit] button on the heading itself.
|
||||
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
|
||||
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||
startOffset := int(firstDay.Weekday()+6) % 7
|
||||
daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
|
||||
var weeks [][]calDay
|
||||
week := make([]calDay, 7)
|
||||
col := startOffset
|
||||
for d := 1; d <= daysInMonth; d++ {
|
||||
cell := calDay{
|
||||
Num: d,
|
||||
HasEntry: hasDayEntry[d],
|
||||
URL: dayAnchor(year, month, d),
|
||||
}
|
||||
cell.IsCurrent = currentDay > 0 && d == currentDay
|
||||
cell.IsToday = d == today.Day() &&
|
||||
time.Month(month) == today.Month() &&
|
||||
year == today.Year()
|
||||
week[col] = cell
|
||||
col++
|
||||
if col == 7 {
|
||||
weeks = append(weeks, week)
|
||||
week = make([]calDay, 7)
|
||||
col = 0
|
||||
}
|
||||
}
|
||||
if col > 0 {
|
||||
weeks = append(weeks, week)
|
||||
}
|
||||
return weeks
|
||||
}
|
||||
|
||||
|
||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||
type diaryPhoto struct {
|
||||
Date time.Time
|
||||
@@ -350,41 +553,20 @@ type diaryPhoto struct {
|
||||
ThumbURL string
|
||||
}
|
||||
|
||||
type diaryMonthSummary struct {
|
||||
ID string
|
||||
Name string
|
||||
URL 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 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/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 +584,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 +626,196 @@ 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 year page: every section from the year file
|
||||
// (with photos attached to `### YYYY-MM-DD` headings) plus virtual entries
|
||||
// for every month/day slot the file doesn't yet contain.
|
||||
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 := buildFileSections(sections, photos, yearURL)
|
||||
out = appendVirtualEntries(out, sections, photos, year, yearURL)
|
||||
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() {
|
||||
// buildFileSections converts the year file's sections (skipping the
|
||||
// pre-heading section 0) into rendered diarySection entries. Photos are
|
||||
// attached to level-3 headings whose text parses as `YYYY-MM-DD`.
|
||||
func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
|
||||
var out []diarySection
|
||||
for i := 1; i < len(sections); i++ {
|
||||
level, text := sectionHeading(sections[i])
|
||||
if level == 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(e.Name())
|
||||
if err != nil || n < 1 || n > 12 {
|
||||
sec := diarySection{
|
||||
Level: level,
|
||||
ID: text,
|
||||
Heading: text,
|
||||
EditURL: fmt.Sprintf("%s?edit§ion=%d", yearURL, i),
|
||||
Body: sectionBody(sections[i]),
|
||||
}
|
||||
if level == 3 {
|
||||
if y, m, d, ok := parseISODate(text); ok {
|
||||
sec.Photos = filterPhotos(photos, y, m, d)
|
||||
}
|
||||
}
|
||||
out = append(out, sec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appendVirtualEntries inserts virtual month and day sections for every
|
||||
// `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section.
|
||||
// Virtual day sections carry photos when present. Each virtual entry's
|
||||
// EditURL routes through the insert-before flow so clicking [edit] splices
|
||||
// the section into the year file at the right chronological position.
|
||||
//
|
||||
// Scope: past years get all 12 months / 365(6) days; the current year stops
|
||||
// at today; future years are returned unchanged.
|
||||
//
|
||||
// Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their
|
||||
// document position; virtual entries are spliced in lexicographic ID order
|
||||
// before the next real date section. Non-date headings (e.g. `## Events` →
|
||||
// `### Festival` in a year intro) are left where the user wrote them.
|
||||
func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection {
|
||||
today := time.Now()
|
||||
if year > today.Year() {
|
||||
return existing
|
||||
}
|
||||
|
||||
coveredMonth := map[string]bool{}
|
||||
coveredDay := map[string]bool{}
|
||||
for _, s := range existing {
|
||||
switch {
|
||||
case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID):
|
||||
coveredMonth[s.ID] = true
|
||||
case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID):
|
||||
coveredDay[s.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
photoByDay := map[string][]diaryPhoto{}
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() != year {
|
||||
continue
|
||||
}
|
||||
monthSet[n] = true
|
||||
monthDirs[n] = e.Name()
|
||||
}
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year {
|
||||
monthSet[int(p.Date.Month())] = true
|
||||
}
|
||||
photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
|
||||
}
|
||||
|
||||
monthNums := make([]int, 0, len(monthSet))
|
||||
for m := range monthSet {
|
||||
monthNums = append(monthNums, m)
|
||||
lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
|
||||
if year == today.Year() {
|
||||
lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
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)
|
||||
dirName, ok := monthDirs[monthNum]
|
||||
if !ok {
|
||||
dirName = fmt.Sprintf("%02d", monthNum)
|
||||
}
|
||||
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,
|
||||
var virtual []diarySection
|
||||
for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
|
||||
if d.Day() == 1 {
|
||||
monthID := d.Format("2006-01")
|
||||
if !coveredMonth[monthID] {
|
||||
idx := computeInsertIndex(sections, monthID)
|
||||
virtual = append(virtual, diarySection{
|
||||
Level: 2,
|
||||
ID: monthID,
|
||||
Heading: monthID,
|
||||
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
|
||||
yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
dayID := d.Format("2006-01-02")
|
||||
if !coveredDay[dayID] {
|
||||
idx := computeInsertIndex(sections, dayID)
|
||||
virtual = append(virtual, diarySection{
|
||||
Level: 3,
|
||||
ID: dayID,
|
||||
Heading: dayID,
|
||||
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
||||
yearURL, idx, url.QueryEscape(dayID)),
|
||||
Photos: photoByDay[dayID],
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(virtual) == 0 {
|
||||
return existing
|
||||
}
|
||||
|
||||
out := make([]diarySection, 0, len(existing)+len(virtual))
|
||||
vi := 0
|
||||
for _, s := range existing {
|
||||
if isRealDateSection(s) {
|
||||
for vi < len(virtual) && virtual[vi].ID < s.ID {
|
||||
out = append(out, virtual[vi])
|
||||
vi++
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
for vi < len(virtual) {
|
||||
out = append(out, virtual[vi])
|
||||
vi++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isRealDateSection reports whether a rendered diarySection is one of the
|
||||
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
|
||||
// interleave sorts against.
|
||||
func isRealDateSection(s diarySection) bool {
|
||||
switch s.Level {
|
||||
case 2:
|
||||
return len(s.ID) == 7 && isDateHeading(s.ID)
|
||||
case 3:
|
||||
return len(s.ID) == 10 && isDateHeading(s.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
|
||||
// if the prefix does not match.
|
||||
func parseISODate(s string) (year, month, day int, ok bool) {
|
||||
if len(s) < 10 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s[:10])
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return t.Year(), int(t.Month()), t.Day(), true
|
||||
}
|
||||
|
||||
func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto {
|
||||
var out []diaryPhoto
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderDiaryContent(sections []diarySection) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := 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)
|
||||
if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
|
||||
log.Printf("diary content template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
if s := r.URL.Query().Get("insert_before"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
||||
insertBefore = n
|
||||
}
|
||||
}
|
||||
|
||||
var rendered template.HTML
|
||||
if len(rawMD) > 0 && !editMode {
|
||||
rendered = renderMarkdown(rawMD)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user