Compare commits

...
10 Commits
Author SHA1 Message Date
luxick 9fadfda70d Prevent flashing during navigation 2026-09-30 16:00:10 +02:00
luxick b5a8d3d003 Only show chevron in trees where appropriate 2026-09-30 15:52:06 +02:00
luxick 3170d1c6d9 Further performance optimization 2026-09-30 15:48:30 +02:00
luxick 65ce1b414e Improve caching 2026-09-28 22:19:17 +02:00
luxick 3070471c38 Optimize binary size and thumbnail performance 2026-09-28 22:12:02 +02:00
luxick 189635d103 Improvements to fitness page
- Data now comes from a calendar
- Hovering in charts shows day info
- Optical enhancements to chart rendering
- Allow setting/showing goal weight
- Allow toggeling between absolute and zoomed in weight axis
2026-09-27 09:31:01 +02:00
luxick 4116a0c332 Add Calendar integration (read-only) 2026-09-25 11:19:28 +02:00
luxick c84f233bbb Update README.md 2026-09-25 09:52:01 +02:00
luxick ef98177e58 Improve special char and emoji handling in tables 2026-09-25 09:38:55 +02:00
luxick ebfd382706 Show creation prompt for non empty folders 2026-09-25 09:38:55 +02:00
45 changed files with 2831 additions and 402 deletions
+7 -3
View File
@@ -4,6 +4,10 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
# Release builds drop the symbol table, DWARF debug info and local source
# paths. Panics still print file:line stack traces; only debuggers lose out.
RELEASE_FLAGS := -trimpath -ldflags="-s -w"
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
@@ -14,10 +18,10 @@ EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-bu
companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
$(COMPANION_WIN): $(COMPANION_SRCS)
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H windowsgui" -o $@ ./cmd/companion
$(COMPANION_LIN): $(COMPANION_SRCS)
GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -o $@ ./cmd/companion
companion-windows: $(COMPANION_WIN)
companion-linux: $(COMPANION_LIN)
@@ -37,7 +41,7 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS)
cd editor-build && npm ci && npm run build
deploy: companion-release
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
GOOS=linux GOARCH=arm GOARM=7 go build $(RELEASE_FLAGS) -o datascape-arm .
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
+84 -75
View File
@@ -4,117 +4,126 @@ Minimal self-hosted personal wiki. Folders are pages.
## Features
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
- **Pages**: a folder's `index.md` renders as HTML, and the folder's other files are listed below it. Going to a missing path lets you create the page there.
- **Editing**: edit the whole page or a single heading's section. Tick task checkboxes directly on the rendered page.
- **Move, rename, delete**: moving a page onto an empty folder offers to merge into it.
- **View settings**: show the file list as a list or thumbnail grid, sorted by name, date, or size. Videos get thumbnails when `ffmpeg` is installed.
- **Search**: find pages by name.
- **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with `![caption](photo.jpg "left")`.
- **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser.
- **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness).
- **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page.
- **Quick-add bookmarklet**: save the current tab to a wiki page.
- **Companion app**: an optional desktop helper, downloadable from the wiki footer. It opens wiki files in local apps and folders in the file manager.
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
## Build & run
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `![caption](photo.jpg "left")` — relative to the page they sit on.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
## Build
The wiki binary embeds the companion binaries, so build those first:
```bash
# local
go build -o datascape .
# QNAP NAS (linux/arm)
GOOS=linux GOARCH=arm go build -o datascape .
make companion-release # once, and after companion changes
go build -o datascape . # local
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
./datascape -dir ./wiki -addr :8080 -user me -pass secret
```
## Usage
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
| Flag | Default | Description |
|------|---------|-------------|
| Flag | Default | |
|------|---------|-|
| `-addr` | `:8080` | Listen address |
| `-dir` | `./wiki` | Wiki root directory |
| `-cache` | `./cache` | Thumbnail cache directory |
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
| `-pass` | _(none)_ | Basic auth password |
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
| `-dir` | `./wiki` | Wiki root |
| `-cache` | `./cache` | Thumbnail cache |
| `-user`, `-pass` | | Basic auth (omit to disable) |
| `-reindex-interval` | `30m` | Search index rebuild (`0` = off) |
| `-calendars` | `Calendars` | [Calendars](#calendars) folder, relative to `-dir` (empty = off) |
| `-tz` | system | Time zone for calendars and the diary, e.g. `Europe/Berlin` |
## View Settings
## `.page-settings`
The **view** button in a folder's `Files` header sets how its listing renders,
persisting three keys to `.page-settings`:
Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys.
| Key | Values (default first) |
|------|------------------------|
|-----|------------------------|
| `view` | `list`, `thumbnail` |
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
| `sort` | `name`, `modified`, `size` |
| `order` | `asc`, `desc` |
| `type` | `diary`, `fitness`, `calendars` |
## Special Folder Types
## Keyboard shortcuts
A folder can opt into special rendering by adding a `.page-settings` file. The
same file also holds the [View Settings](#view-settings) keys; only the `type`
key selects a special renderer:
All use Alt+Shift.
| Where | Keys |
|-------|------|
| Anywhere | `E` edit, `N` new page, `M` move, `F` search, `H` maintenance |
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
## Diary
```
type = diary
Diary/
.page-settings ← type = diary
2026/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
2026-05-11 Beach.jpg ← photos go under their date's section
```
### Diary
The year page shows each day's section with its photos. Months and days that are missing (up to today) appear as placeholders with an `[edit]` button. A calendar in the sidebar jumps to any date.
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.
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
## Fitness
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
## Calendars
```
FolderName/
.page-settings ← type = diary
YYYY/
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
Calendars/
.page-settings ← type = calendars, roles, sync credentials
Personal.ics ← one file per calendar
Gewicht.ics
…
```
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.
Pages only read these local files and never contact the calendar server. The built-in sync downloads all calendars of a CalDAV calendar home in the background. Or you can put the `.ics` files there yourself.
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.
```
type = calendars
#### Persistent date links
# Roles: file names without .ics
diary = Personal, Feiertage
weight = Gewicht
maintenance = Maintainance
Each diary root exposes three stable paths intended for browser bookmarks.
They resolve against the year page rather than separate per-day URLs:
# Sync (Nextcloud)
caldav-url = https://cloud.example/remote.php/dav/calendars/me/
caldav-user = me
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
sync-interval = 15m
```
| Path | Redirects to |
|------|-------------|
| `<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/` |
| Key | |
|-----|-|
| `diary` | Events shown under each diary day: all-day first, then by start time |
| `weight` | Titles like `95,6kg`, shown under the diary day's events |
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
| `caldav-url` | Calendar home. Empty = no sync |
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder.
## Quick-Add Bookmarklet
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
## Quick-add bookmarklet
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page:
```javascript
javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})();
```
Each save appends an entry of the following form to the destination page's `index.md`:
Each save adds this to the target page's `index.md`:
```markdown
- [Example Page](https://example.com)
+45
View File
@@ -0,0 +1,45 @@
<h2 id="maintenance">Maintenance</h2>
{{if .Maintenance}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Task</th><th>Repeats</th><th>Last</th><th>Next</th></tr></thead>
<tbody>
{{range .Maintenance}}
<tr><td>{{.Summary}}</td><td class="muted">{{.Rule}}</td><td class="nowrap">{{.Last}}</td><td class="nowrap">{{.Next}}</td></tr>
{{end}}
</tbody>
</table></div>
{{else if .HasMaintenance}}
<p class="muted">No maintenance tasks within a year of today.</p>
{{else}}
<p class="muted">No maintenance calendar. Set <code>maintenance = &lt;name&gt;</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
<h2 id="calendars">Calendars</h2>
{{if .Calendars}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Calendar</th><th>Role</th><th>Events</th><th>Updated</th></tr></thead>
<tbody>
{{range .Calendars}}
<tr>
<td>{{.Name}}{{if .Error}}<div class="cal-error">{{.Error}}</div>{{end}}</td>
<td class="muted">{{.Roles}}</td>
<td>{{.Events}}</td>
<td class="nowrap">{{.Updated}}</td>
</tr>
{{end}}
</tbody>
</table></div>
{{else}}
<p class="muted">No <code>.ics</code> files in this folder yet.</p>
{{end}}
{{if .IsSyncDir}}
{{if .SyncEnabled}}
<form class="cal-sync row" method="post" action="?calsync">
<button type="submit" class="btn">Sync now</button>
<span class="muted">{{if .LastRun}}Last sync: {{.LastRun}}{{else}}Not synced yet{{end}}</span>
</form>
{{if .LastError}}<p class="cal-error">{{.LastError}}</p>{{end}}
{{else}}
<p class="muted">Sync is off. Set <code>caldav-url</code>, <code>caldav-user</code> and <code>caldav-pass</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
{{end}}
+16
View File
@@ -0,0 +1,16 @@
<span class="dropdown maint">
<button type="button" class="btn maint-toggle{{if not .Count}} is-idle{{end}}" data-action="maint-menu" aria-haspopup="true" aria-expanded="false" title="Maintenance (H)">{{icon "maintenance"}}<span class="maint-count">{{.Count}}</span></button>
<div class="dropdown-menu align-right maint-menu">
<div class="panel-header">Today</div>
<div class="maint-list">
{{range .Today}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing due</span>{{end}}
</div>
<div class="panel-header">Next 7 days</div>
<div class="maint-list">
{{range .Upcoming}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing planned</span>{{end}}
</div>
{{if .PageURL}}<a class="btn btn-block maint-all" href="{{.PageURL}}">All maintenance →</a>{{end}}
</div>
</span>
+1 -1
View File
@@ -24,4 +24,4 @@
</table>
{{end}}
</div>
<script src="/_/diary/calendar.js"></script>
<script src="{{asset "diary/calendar.js"}}"></script>
+4
View File
@@ -10,4 +10,8 @@
{{end}}
</div>
{{end}}
{{if or .Events .Weight}}<div class="diary-day-cal muted">
{{if .Events}}<p>{{.Events}}</p>{{end}}
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
</div>{{end}}
{{end}}
+6 -6
View File
@@ -78,10 +78,10 @@
<div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/editor/main.js"></script>
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="{{asset "editor/tables.js"}}"></script>
<script src="{{asset "editor/dates.js"}}"></script>
<script src="{{asset "editor/movie.js"}}"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="{{asset "editor/main.js"}}"></script>
{{end}}
+23 -10
View File
@@ -6,22 +6,34 @@ window.EditorTables = (function () {
return s;
}
var segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
// Graphemes rendered two columns wide: emoji-presentation characters, anything
// forced to emoji presentation via VS16, and East Asian wide/fullwidth ranges.
var WIDE = /\p{Emoji_Presentation}|️|[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
var ZERO = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
function displayWidth(s) {
var w = 0;
Array.from(segmenter.segment(s), function (seg) {
var g = seg.segment;
if (!ZERO.test(g)) w += WIDE.test(g) ? 2 : 1;
});
return w;
}
function padLeft(s, w) {
while (s.length < w) s = ' ' + s;
return s;
return repeat(' ', w - displayWidth(s)) + s;
}
function padRight(s, w) {
while (s.length < w) s += ' ';
return s;
return s + repeat(' ', w - displayWidth(s));
}
function padCenter(s, w) {
while (s.length < w) {
s = s + ' ';
if (s.length < w) s = ' ' + s;
}
return s;
var total = w - displayWidth(s);
var left = Math.floor(total / 2);
return repeat(' ', left) + s + repeat(' ', total - left);
}
function parseTableRow(line) {
@@ -101,7 +113,8 @@ window.EditorTables = (function () {
var max = 3;
for (var r = 0; r < rows.length; r++) {
if (r === sepIndex) continue;
if (rows[r][c].length > max) max = rows[r][c].length;
var len = displayWidth(rows[r][c]);
if (len > max) max = len;
}
widths.push(max);
}
+70 -6
View File
@@ -1,10 +1,74 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that
// chart's query parameter updated. Plain GET navigation — each range is a
// distinct, bookmarkable view, so no postReplace/history handling is needed.
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
// reloads the page with that chart's query parameter updated. Plain GET
// navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
var sel = e.target.closest('[data-fitness-range]');
var abs = e.target.closest('[data-fitness-absolute]');
if (sel) {
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
} else if (abs) {
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
} else {
return;
}
window.location.href = url.toString();
});
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
// are sorted by x, so a binary search finds it; the tooltip sits above the
// point and is clamped to the chart panel.
function fitnessNearest(dots, x) {
var lo = 0, hi = dots.length - 1;
while (lo < hi) {
var mid = (lo + hi) >> 1;
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
}
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
return dots[lo];
}
function fitnessHide(svg) {
var chart = svg.closest('.fitness-chart');
svg.classList.remove('is-hover');
chart.querySelector('.fitness-tip').hidden = true;
}
document.addEventListener('pointermove', function (e) {
var svg = e.target.closest && e.target.closest('.fitness-svg');
document.querySelectorAll('.fitness-svg').forEach(function (other) {
if (other !== svg) fitnessHide(other);
});
if (!svg) return;
var dots = svg.querySelectorAll('.chart-dot');
if (!dots.length) return;
var ctm = svg.getScreenCTM();
if (!ctm) return;
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
var dot = fitnessNearest(dots, pt.x);
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
var cursor = svg.querySelector('.chart-cursor');
cursor.setAttribute('x1', cx);
cursor.setAttribute('x2', cx);
var focus = svg.querySelector('.chart-focus');
focus.setAttribute('cx', cx);
focus.setAttribute('cy', cy);
svg.classList.add('is-hover');
var chart = svg.closest('.fitness-chart');
var tip = chart.querySelector('.fitness-tip');
tip.firstElementChild.textContent = dot.dataset.date;
tip.lastElementChild.textContent = dot.dataset.value;
tip.hidden = false;
var box = chart.getBoundingClientRect();
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
var left = at.x - box.left - tip.offsetWidth / 2;
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
var top = at.y - box.top - tip.offsetHeight - 10;
if (top < 0) top = at.y - box.top + 10;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
});
+13 -6
View File
@@ -2,19 +2,23 @@
<section class="fitness-chart panel">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
<div class="row fitness-controls">
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
</div>
{{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p>
{{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}}
{{range .XTicks}}
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -23,13 +27,16 @@
<polyline class="chart-line" points="{{.}}"/>
{{end}}
{{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/>
{{end}}
{{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}}
</section>
{{end}}
@@ -40,4 +47,4 @@
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}}
</div>
<script src="/_/fitness/fitness.js"></script>
<script src="{{asset "fitness/fitness.js"}}"></script>
+15 -7
View File
@@ -16,6 +16,12 @@
e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break;
case 'H':
var maint = document.querySelector('[data-action="maint-menu"]');
if (!maint) return;
e.preventDefault();
maint.click();
break;
case 'F':
var input = document.querySelector('.search-input');
if (!input) return;
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
['actions-menu', 'maint-menu'].forEach(function (action) {
var trigger = document.querySelector('[data-action="' + action + '"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
});
});
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 7v4M9 7v4"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 3h12v11H2zM2 6h12M5 1v2M11 1v2M5 10l2 2 4-4"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 8h8M7 5l3 3-3 3M13 2v12"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M8 3v10M3 8h10"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M13 8a5 5 0 1 1-1.5-3.5M12 1v4H8"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M1 14V3h5l1 2h8v9zM8 12V8M6 10l2-2 2 2"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+27 -11
View File
@@ -4,19 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/history-nav.js"></script>
<script src="/_/scroll-sync.js" defer></script>
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
<link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="{{asset "modal.js"}}"></script>
<script src="{{asset "global-shortcuts.js"}}"></script>
<script src="{{asset "history-nav.js"}}"></script>
<script src="{{asset "scroll-sync.js"}}" defer></script>
<script src="{{asset "search-suggest.js"}}" defer></script>
<script src="{{asset "tree-picker.js"}}"></script>
<script src="{{asset "companion.js"}}" defer></script>
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body>
@@ -31,6 +31,7 @@
{{end}}
<div class="header-actions row">
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
{{maintenanceWidget}}
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
@@ -44,6 +45,21 @@
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
<script>
// Paint the previous page's tree before first render so the rail
// doesn't flash empty; tree-sidebar.js swaps in the live tree.
(function () {
try {
var s = JSON.parse(sessionStorage.getItem("tree-snapshot"));
if (!s) return;
var el = document.currentScript.previousElementSibling;
el.innerHTML = s.html;
var a = el.querySelector('a.tree-name[href="' + CSS.escape(location.pathname.replace(/\/*$/, "/")) + '"]');
if (a) a.parentNode.classList.add("is-active");
el.scrollTop = s.top;
} catch (e) {}
})();
</script>
</aside>{{end}}
<div class="center">
<main>
+15 -16
View File
@@ -1,13 +1,13 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
{{end}}{{end}}
{{define "content"}}
@@ -17,6 +17,9 @@
{{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if not (or .Content .SpecialContent)}}
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
{{end}}
{{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
{{if eq .View "thumbnail"}}
@@ -41,21 +44,17 @@
</tbody>
</table>
{{end}}
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
{{else if not .Content}}
{{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
{{end}}
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script>
<script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
<script src="{{asset "page/tasks.js"}}"></script>
{{end}}
{{if .Content}}
<script src="/_/page/sections.js"></script>
<script src="{{asset "page/sections.js"}}"></script>
{{end}}
<script src="/_/page/contents.js"></script>
<script src="{{asset "page/contents.js"}}"></script>
{{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Save link</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="stylesheet" href="/_/style.css" />
<link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="stylesheet" href="{{asset "style.css"}}" />
<style>
body { padding: 0.8rem; }
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
+2 -2
View File
@@ -1,4 +1,4 @@
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}}
@@ -43,7 +43,7 @@
{{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index"><span class="btn-icon">{{icon "rebuild"}}</span><span class="btn-label">REBUILD INDEX</span></button>
{{end}}
{{define "footerExtras"}}
+81 -4
View File
@@ -48,6 +48,28 @@
--reading-width: 60rem;
}
/* Cross-document view transitions: page navigations stay full reloads, but the
browser holds the old frame until the new page is ready and swaps them
without a blank flash. The header and tree rail are named so they swap in
place (no crossfade) — only the rest of the page fades, and only briefly. */
@view-transition { navigation: auto; }
body > header { view-transition-name: app-header; }
.tree-sidebar { view-transition-name: tree-sidebar; }
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation-duration: 0.12s; }
::view-transition-group(app-header),
::view-transition-group(tree-sidebar),
::view-transition-old(app-header),
::view-transition-new(app-header),
::view-transition-old(tree-sidebar),
::view-transition-new(tree-sidebar) { animation: none; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none; }
}
/* Anchor / TOC jumps are handled by the .center scroll container's own
scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */
@@ -389,6 +411,10 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;}
/* Header actions carry an icon and a label: the mobile dropdown shows the
label, the inline desktop bar (see responsive block) the icon. */
.btn-icon { display: none; }
.btn-icon svg { display: block; }
/* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -797,13 +823,46 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current,
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { color: var(--primary-hover); }
/* Calendar events and weight under a diary day heading. */
.diary-day-cal { margin: var(--space-3) 0 0; }
.diary-day-cal p { margin: 0; }
/* === Calendars ===
Header maintenance chip + dropdown, and the calendars folder page. */
.maint-toggle { display: inline-flex; align-items: center; }
.maint-count { margin-left: 0.3rem; }
.maint-toggle.is-idle { color: var(--text-muted); }
.maint-menu {
min-width: 20rem;
max-width: calc(100vw - 2rem);
padding: var(--space-2) var(--space-3);
white-space: normal;
}
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
.maint-list {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-2);
row-gap: 0.15rem;
font-size: var(--font-sm);
}
.maint-list > .muted { grid-column: 1 / -1; }
.maint-list + .panel-header { margin-top: var(--space-3); }
.maint-when { color: var(--text-muted); white-space: nowrap; }
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
.cal-scroll { overflow-x: auto; }
.cal-table .nowrap { white-space: nowrap; }
.cal-error { color: var(--danger); font-size: var(--font-sm); }
.cal-sync { margin-top: var(--space-3); }
/* === Fitness dashboard ===
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
colors and strokes are applied here via classes so the inline SVG follows
the theme palette. */
.fitness-chart { padding: var(--space-3); }
.fitness-chart { position: relative; padding: var(--space-3); }
.fitness-chart-header { margin-bottom: var(--space-2); }
.fitness-controls { gap: var(--space-3); }
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
.fitness-range { width: auto; font-size: var(--font-sm); }
.fitness-empty {
border: var(--border-dashed);
@@ -817,8 +876,24 @@ aside.sidebar:empty { display: none; }
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
.fitness-svg .chart-dot { fill: var(--link); }
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
.fitness-svg.is-dense .chart-dot { display: none; }
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; }
.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; }
.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; }
.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; }
.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); }
.fitness-tip {
position: absolute;
display: flex;
flex-direction: column;
padding: var(--space-1) var(--space-2);
background: var(--bg);
border: var(--border);
font-size: var(--font-sm);
white-space: nowrap;
pointer-events: none;
}
.fitness-tip[hidden] { display: none; }
/* === Responsive === */
@media (max-width: 1100px) {
@@ -840,7 +915,9 @@ aside.sidebar:empty { display: none; }
@media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
.actions-menu .btn-block { width: auto; padding: 0;}
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; }
.actions-menu .btn-icon { display: inline-block; }
.actions-menu .btn-label { display: none; }
}
@media (max-width: 600px) {
+34 -4
View File
@@ -40,6 +40,7 @@
}
})()
var activeRow = null
var live = false // set once the fetched tree is on screen
function disabledRow(text) {
var row = document.createElement("div")
@@ -84,7 +85,8 @@
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = false
var isLeaf = !entry.hasChildren
if (isLeaf) chevron.classList.add("is-leaf")
function ensureChildrenEl() {
if (!childrenEl) {
@@ -168,15 +170,26 @@
}
function render() {
container.textContent = ""
// If the layout pre-painted a snapshot of the previous page's tree, keep it
// on screen and build the live tree off-DOM, swapping it in once ready —
// no "…" placeholder, no scroll jump.
var listing = document.createElement("div")
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
var hasSnapshot = container.firstChild !== null
if (!hasSnapshot) {
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
}
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (hasSnapshot) {
var top = container.scrollTop
container.replaceChildren(listing)
container.scrollTop = top
}
live = true
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
@@ -184,17 +197,34 @@
}
})
.catch(function () {
live = false
container.replaceChildren(listing)
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
container.textContent = ""
render()
})
listing.appendChild(err)
})
}
// Snapshot the tree (markup + scroll) on the way out so the next page's
// layout can paint it immediately. Skipped until the live tree has rendered,
// so a placeholder or error row never becomes the snapshot. The active
// highlight is left out; the next page marks its own row.
window.addEventListener("pagehide", function () {
if (!live) return
if (activeRow) activeRow.classList.remove("is-active")
var html = container.innerHTML
if (activeRow) activeRow.classList.add("is-active") // bfcache may restore this page
try {
sessionStorage.setItem("tree-snapshot", JSON.stringify({ html: html, top: container.scrollTop }))
} catch (e) {}
})
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
+677
View File
@@ -0,0 +1,677 @@
package main
import (
"bytes"
"errors"
"fmt"
"log"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/emersion/go-ical"
"github.com/teambition/rrule-go"
)
// calendarsDir is the absolute path of the calendars folder (-calendars).
// Empty disables the diary lines, the maintenance widget and the sync. Every
// calendar is one <Name>.ics file in this folder; the folder's .page-settings
// assigns calendars to roles and holds the CalDAV sync credentials.
var calendarsDir string
// === Settings ===
type calendarSettings struct {
// Roles: calendar names (file basenames without .ics).
Diary []string
Weight []string
Maintenance []string
CaldavURL string
CaldavUser string
CaldavPass string
SyncInterval time.Duration // 0 = startup and manual only
}
// readCalendarSettings reads the calendar keys from dir's .page-settings.
// Read on every use, so edits apply without a restart.
func readCalendarSettings(dir string) calendarSettings {
kv := readSettingsFile(dir)
s := calendarSettings{
Diary: splitCalendarList(kv["diary"]),
Weight: splitCalendarList(kv["weight"]),
Maintenance: splitCalendarList(kv["maintenance"]),
CaldavURL: kv["caldav-url"],
CaldavUser: kv["caldav-user"],
CaldavPass: kv["caldav-pass"],
SyncInterval: 15 * time.Minute,
}
if v, ok := kv["sync-interval"]; ok {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
s.SyncInterval = d
}
}
return s
}
func splitCalendarList(v string) []string {
var out []string
for _, name := range strings.Split(v, ",") {
if name = strings.TrimSpace(name); name != "" {
out = append(out, name)
}
}
return out
}
// calendarPath returns the file of calendar name in dir, or "" when the name
// would escape the folder.
func calendarPath(dir, name string) string {
if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") {
return ""
}
return filepath.Join(dir, name+".ics")
}
// === Model ===
// calEvent is one expanded occurrence.
type calEvent struct {
Calendar string
UID string
Summary string
Start time.Time // local time; all-day events start at local midnight
End time.Time // exclusive
AllDay bool
Stamp time.Time // LAST-MODIFIED, else DTSTAMP
Series *calSeries // nil for single events
}
// calSeries describes a recurring event as a whole.
type calSeries struct {
Calendar string
UID string
Summary string
Rule string // human-readable, e.g. "every 2 weeks (Sa)"
}
// calItem is a parsed VEVENT: a single event, a recurring master, or a
// RECURRENCE-ID override attached to its master.
type calItem struct {
uid string
summary string
start time.Time
end time.Time
allDay bool
days int // all-day length in days; keeps occurrences DST-safe
stamp time.Time
cancelled bool
set *rrule.Set // nil = single event
series *calSeries // set together with set
overrides map[int64]*calItem // RECURRENCE-ID (unix) → replacement
}
// calFile is one parsed calendar file.
type calFile struct {
modTime time.Time
size int64
items []*calItem
events int // VEVENT components in the file
}
// === Store ===
// calStore caches parsed calendar files by path. An entry is reused while the
// file's modtime and size are unchanged, so a request costs one stat per
// calendar. Parsed files are never mutated, so callers may keep using a
// snapshot after the lock is released.
var calStore = struct {
sync.Mutex
files map[string]*calFile
}{files: map[string]*calFile{}}
// loadCalendar returns the parsed calendar at path; nil when it does not exist
// or cannot be parsed (logged).
func loadCalendar(path string) *calFile {
info, err := os.Stat(path)
if err != nil {
return nil
}
calStore.Lock()
defer calStore.Unlock()
if f := calStore.files[path]; f != nil && f.modTime.Equal(info.ModTime()) && f.size == info.Size() {
return f
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
f, err := parseCalendar(filepath.Base(path), data)
if err != nil {
log.Printf("calendar %s: %v", path, err)
delete(calStore.files, path)
return nil
}
f.modTime = info.ModTime()
f.size = info.Size()
calStore.files[path] = f
return f
}
// calendarOccurrences expands every event of the named calendars in dir that
// overlaps [from, to). The result is sorted by start, then summary.
func calendarOccurrences(dir string, names []string, from, to time.Time) []calEvent {
var out []calEvent
for _, name := range names {
p := calendarPath(dir, name)
if p == "" {
continue
}
f := loadCalendar(p)
if f == nil {
continue
}
for _, it := range f.items {
out = it.expand(name, from, to, out)
}
}
sort.SliceStable(out, func(i, j int) bool {
if !out[i].Start.Equal(out[j].Start) {
return out[i].Start.Before(out[j].Start)
}
return out[i].Summary < out[j].Summary
})
return out
}
func (it *calItem) occurrence(calendar string, start, end time.Time) calEvent {
return calEvent{
Calendar: calendar,
UID: it.uid,
Summary: it.summary,
Start: start.In(time.Local),
End: end.In(time.Local),
AllDay: it.allDay,
Stamp: it.stamp,
}
}
// overlaps reports whether [start, end) intersects [from, to). Zero-length
// events count when they start inside the window.
func overlaps(start, end, from, to time.Time) bool {
if !start.Before(to) {
return false
}
return end.After(from) || !start.Before(from)
}
// expand appends the item's occurrences in [from, to) to out.
func (it *calItem) expand(calendar string, from, to time.Time, out []calEvent) []calEvent {
if it.cancelled {
return out
}
if it.set == nil {
if overlaps(it.start, it.end, from, to) {
out = append(out, it.occurrence(calendar, it.start, it.end))
}
return out
}
// Start the search early enough to catch occurrences that began before
// the window but still run into it.
after := from.Add(-it.end.Sub(it.start)).Add(-48 * time.Hour)
for _, s := range it.set.Between(after, to, true) {
if _, overridden := it.overrides[s.Unix()]; overridden {
continue
}
e := it.occurrenceEnd(s)
if overlaps(s, e, from, to) {
ev := it.occurrence(calendar, s, e)
ev.Series = it.series
out = append(out, ev)
}
}
// Overrides are placed on their own dates: a moved occurrence can enter
// or leave the window independently of its original slot.
for _, o := range it.overrides {
if o.cancelled || !overlaps(o.start, o.end, from, to) {
continue
}
ev := o.occurrence(calendar, o.start, o.end)
ev.Series = it.series
out = append(out, ev)
}
return out
}
// occurrenceEnd returns the end of the occurrence starting at s.
func (it *calItem) occurrenceEnd(s time.Time) time.Time {
if it.allDay {
return s.AddDate(0, 0, it.days)
}
return s.Add(it.end.Sub(it.start))
}
// === Parsing ===
// calParser holds per-file parse state: the file name for log messages, the
// TZIDs already warned about, and resolved locations.
type calParser struct {
file string
zones map[string]*time.Location
warned map[string]bool
}
func parseCalendar(file string, data []byte) (*calFile, error) {
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
cal, err := ical.NewDecoder(bytes.NewReader(data)).Decode()
if err != nil {
return nil, err
}
p := &calParser{file: file, zones: map[string]*time.Location{}, warned: map[string]bool{}}
f := &calFile{}
masters := map[string]*calItem{}
type override struct {
item *calItem
recurID time.Time
}
var overrides []override
for _, comp := range cal.Children {
if comp.Name != ical.CompEvent {
continue
}
f.events++
it, recurID, err := p.item(comp)
if err != nil {
continue
}
if !recurID.IsZero() {
overrides = append(overrides, override{it, recurID})
continue
}
f.items = append(f.items, it)
if it.uid != "" {
masters[it.uid] = it
}
}
// Overrides can come before their master in the file, so attach them in
// a second pass. One without a recurring master is shown as a single event.
for _, o := range overrides {
m := masters[o.item.uid]
if m == nil || m.set == nil {
f.items = append(f.items, o.item)
continue
}
if o.item.summary == "" {
o.item.summary = m.summary
}
m.overrides[o.recurID.Unix()] = o.item
}
return f, nil
}
// item parses one VEVENT. recurID is non-zero for RECURRENCE-ID overrides.
func (p *calParser) item(comp *ical.Component) (it *calItem, recurID time.Time, err error) {
startProp := comp.Props.Get(ical.PropDateTimeStart)
if startProp == nil {
return nil, time.Time{}, errors.New("no DTSTART")
}
starts, allDay, err := p.times(startProp)
if err != nil || len(starts) == 0 {
return nil, time.Time{}, fmt.Errorf("DTSTART: %v", err)
}
it = &calItem{start: starts[0], allDay: allDay}
it.uid = calText(comp.Props.Get(ical.PropUID))
it.summary = strings.TrimSpace(calText(comp.Props.Get(ical.PropSummary)))
it.end = it.start
if prop := comp.Props.Get(ical.PropDateTimeEnd); prop != nil {
if ends, _, err := p.times(prop); err == nil && len(ends) > 0 {
it.end = ends[0]
}
} else if prop := comp.Props.Get(ical.PropDuration); prop != nil {
if d, err := prop.Duration(); err == nil {
if allDay {
it.end = it.start.AddDate(0, 0, int(math.Round(d.Hours()/24)))
} else {
it.end = it.start.Add(d)
}
}
} else if allDay {
it.end = it.start.AddDate(0, 0, 1)
}
if it.end.Before(it.start) {
it.end = it.start
}
if allDay {
it.days = calendarDaysBetween(it.start, it.end)
if it.days < 1 {
it.days = 1
}
it.end = it.start.AddDate(0, 0, it.days)
}
if strings.EqualFold(calText(comp.Props.Get(ical.PropStatus)), "CANCELLED") {
it.cancelled = true
}
for _, name := range []string{ical.PropLastModified, ical.PropDateTimeStamp} {
if prop := comp.Props.Get(name); prop != nil {
if ts, _, err := p.times(prop); err == nil && len(ts) > 0 {
it.stamp = ts[0]
break
}
}
}
if prop := comp.Props.Get(ical.PropRecurrenceID); prop != nil {
ids, _, err := p.times(prop)
if err != nil || len(ids) == 0 {
return nil, time.Time{}, fmt.Errorf("RECURRENCE-ID: %v", err)
}
return it, ids[0], nil
}
rule := comp.Props.Get(ical.PropRecurrenceRule)
rdates := comp.Props.Values(ical.PropRecurrenceDates)
if rule == nil && len(rdates) == 0 {
return it, time.Time{}, nil
}
set := &rrule.Set{}
ruleText := ""
if rule != nil {
opt, err := rrule.StrToROptionInLocation(rule.Value, it.start.Location())
if err != nil {
p.warn("rrule:"+rule.Value, "cannot parse RRULE %q of %q: %v", rule.Value, it.summary, err)
return it, time.Time{}, nil
}
opt.Dtstart = it.start
r, err := rrule.NewRRule(*opt)
if err != nil {
p.warn("rrule:"+rule.Value, "cannot build RRULE %q of %q: %v", rule.Value, it.summary, err)
return it, time.Time{}, nil
}
set.RRule(r)
ruleText = describeRRule(opt)
} else {
// Without an RRULE the set only holds RDATEs; DTSTART is always the
// first occurrence.
set.RDate(it.start)
ruleText = "on selected dates"
}
for i := range rdates {
if ts, _, err := p.times(&rdates[i]); err == nil {
for _, t := range ts {
set.RDate(t)
}
}
}
for _, prop := range comp.Props.Values(ical.PropExceptionDates) {
if ts, _, err := p.times(&prop); err == nil {
for _, t := range ts {
set.ExDate(t)
}
}
}
it.set = set
it.overrides = map[int64]*calItem{}
it.series = &calSeries{
Calendar: strings.TrimSuffix(p.file, ".ics"),
UID: it.uid,
Summary: it.summary,
Rule: ruleText,
}
return it, time.Time{}, nil
}
// calText unescapes a TEXT value. Unlike go-ical's Text it keeps unescaped
// commas: clients write titles like "95,6kg" without the required backslash,
// and go-ical would cut them at the comma. Unknown escapes are kept as is.
func calText(prop *ical.Prop) string {
if prop == nil {
return ""
}
v := prop.Value
if !strings.Contains(v, `\`) {
return v
}
var sb strings.Builder
for i := 0; i < len(v); i++ {
c := v[i]
if c != '\\' || i+1 == len(v) {
sb.WriteByte(c)
continue
}
i++
switch n := v[i]; n {
case 'n', 'N':
sb.WriteByte('\n')
case '\\', ';', ',':
sb.WriteByte(n)
default:
sb.WriteByte('\\')
sb.WriteByte(n)
}
}
return sb.String()
}
// times parses a DATE or DATE-TIME property. Comma-separated lists (EXDATE,
// RDATE) yield several values. Dates and floating times use the local zone; an
// unknown TZID falls back to it with a warning.
func (p *calParser) times(prop *ical.Prop) ([]time.Time, bool, error) {
loc := time.Local
if tzid := prop.Params.Get(ical.PropTimezoneID); tzid != "" {
loc = p.zone(tzid)
}
var out []time.Time
allDay := false
for _, v := range strings.Split(prop.Value, ",") {
v = strings.TrimSpace(v)
var t time.Time
var err error
switch {
case len(v) == 8:
t, err = time.ParseInLocation("20060102", v, time.Local)
allDay = true
case len(v) == 16 && strings.HasSuffix(v, "Z"):
t, err = time.Parse("20060102T150405Z", v)
case len(v) == 15:
t, err = time.ParseInLocation("20060102T150405", v, loc)
default:
err = fmt.Errorf("unsupported value %q", v)
}
if err != nil {
return nil, false, err
}
out = append(out, t)
}
return out, allDay, nil
}
func (p *calParser) zone(tzid string) *time.Location {
if loc, ok := p.zones[tzid]; ok {
return loc
}
loc, err := time.LoadLocation(tzid)
if err != nil {
// Some clients prefix the IANA name, e.g.
// "/freeassociation.sourceforge.net/Tzfile/Europe/Berlin".
parts := strings.Split(strings.Trim(tzid, "/"), "/")
if len(parts) >= 2 {
loc, err = time.LoadLocation(strings.Join(parts[len(parts)-2:], "/"))
}
}
if err != nil {
p.warn("tz:"+tzid, "unknown TZID %q, using local time", tzid)
loc = time.Local
}
p.zones[tzid] = loc
return loc
}
func (p *calParser) warn(key, format string, args ...any) {
if p.warned[key] {
return
}
p.warned[key] = true
log.Printf("calendar %s: "+format, append([]any{p.file}, args...)...)
}
// calendarDaysBetween counts calendar days from a to b (both local midnights),
// independent of DST shifts.
func calendarDaysBetween(a, b time.Time) int {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
return int(ub.Sub(ua).Hours() / 24)
}
// === Formatting ===
var germanWeekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
// calDayLabel formats a date as "Sa 27.09.".
func calDayLabel(t time.Time) string {
return fmt.Sprintf("%s %02d.%02d.", germanWeekdays[t.Weekday()], t.Day(), int(t.Month()))
}
// describeRRule turns common rules into short text ("weekly (Mo)",
// "every 2 weeks (Sa)", "monthly (day 1)"). Anything with parts it does not
// know falls back to the raw rule.
func describeRRule(opt *rrule.ROption) string {
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 {
return opt.RRuleString()
}
units := map[rrule.Frequency][2]string{
rrule.DAILY: {"daily", "days"},
rrule.WEEKLY: {"weekly", "weeks"},
rrule.MONTHLY: {"monthly", "months"},
rrule.YEARLY: {"yearly", "years"},
}
u, ok := units[opt.Freq]
if !ok {
return opt.RRuleString()
}
text := u[0]
if opt.Interval > 1 {
text = fmt.Sprintf("every %d %s", opt.Interval, u[1])
}
var details []string
for _, wd := range opt.Byweekday {
name := germanWeekdays[(wd.Day()+1)%7]
if n := wd.N(); n != 0 {
name = strconv.Itoa(n) + ". " + name
if n == -1 {
name = "last " + germanWeekdays[(wd.Day()+1)%7]
}
}
details = append(details, name)
}
for _, d := range opt.Bymonthday {
details = append(details, fmt.Sprintf("day %d", d))
}
for _, m := range opt.Bymonth {
if m >= 1 && m <= 12 {
details = append(details, germanMonths[time.Month(m)])
}
}
if len(details) > 0 {
text += " (" + strings.Join(details, ", ") + ")"
}
return text
}
// === Diary lines ===
// calDayLines is the calendar content shown under a diary day.
type calDayLines struct {
Events string // "Feiertag, 10:00 Brunch, 19:30 Kino"
Weight string // "95,6 kg"
}
var weightTitle = regexp.MustCompile(`(?i)^\s*(\d+(?:[.,]\d+)?)\s*kg\s*$`)
// calendarDayLines builds the diary lines for every day in [from, to), keyed
// by "YYYY-MM-DD". Events come from the diary role, weights from the weight
// role. Days without either are absent.
func calendarDayLines(from, to time.Time) map[string]calDayLines {
out := map[string]calDayLines{}
if calendarsDir == "" {
return out
}
s := readCalendarSettings(calendarsDir)
type dayEvents struct{ allDay, timed []calEvent }
byDay := map[string]*dayEvents{}
for _, ev := range calendarOccurrences(calendarsDir, s.Diary, from, to) {
if ev.AllDay {
// Multi-day all-day events show on every day they cover.
for d := ev.Start; d.Before(ev.End); d = d.AddDate(0, 0, 1) {
key := d.Format("2006-01-02")
if byDay[key] == nil {
byDay[key] = &dayEvents{}
}
byDay[key].allDay = append(byDay[key].allDay, ev)
}
continue
}
key := ev.Start.Format("2006-01-02")
if byDay[key] == nil {
byDay[key] = &dayEvents{}
}
byDay[key].timed = append(byDay[key].timed, ev)
}
for key, d := range byDay {
sort.SliceStable(d.allDay, func(i, j int) bool { return d.allDay[i].Summary < d.allDay[j].Summary })
var parts []string
for _, ev := range d.allDay {
if ev.Summary != "" {
parts = append(parts, ev.Summary)
}
}
// Timed events are already sorted by start, then summary.
for _, ev := range d.timed {
parts = append(parts, strings.TrimSpace(ev.Start.Format("15:04")+" "+ev.Summary))
}
if len(parts) > 0 {
out[key] = calDayLines{Events: strings.Join(parts, ", ")}
}
}
for key, w := range calendarWeights(s, from, to) {
lines := out[key]
lines.Weight = strings.ReplaceAll(w, ".", ",") + " kg"
out[key] = lines
}
return out
}
// calendarWeights reads the weight role for [from, to): one entry per day,
// keyed by "YYYY-MM-DD", holding the kg value with a '.' decimal separator.
// Events whose title is not a weight are skipped; several entries on one day:
// the last edited one wins.
func calendarWeights(s calendarSettings, from, to time.Time) map[string]string {
out := map[string]string{}
stamps := map[string]time.Time{}
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
m := weightTitle.FindStringSubmatch(ev.Summary)
if m == nil {
continue
}
key := ev.Start.Format("2006-01-02")
if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
continue
}
stamps[key] = ev.Stamp
out[key] = strings.ReplaceAll(m[1], ",", ".")
}
return out
}
+283
View File
@@ -0,0 +1,283 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// writeCal writes a VCALENDAR with the given VEVENT bodies to dir/name.ics.
func writeCal(t *testing.T, dir, name string, events ...string) {
t.Helper()
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n")
for _, ev := range events {
b.WriteString("BEGIN:VEVENT\r\n")
for _, line := range strings.Split(strings.TrimSpace(ev), "\n") {
b.WriteString(strings.TrimSpace(line) + "\r\n")
}
b.WriteString("END:VEVENT\r\n")
}
b.WriteString("END:VCALENDAR\r\n")
if err := os.WriteFile(filepath.Join(dir, name+".ics"), []byte(b.String()), 0644); err != nil {
t.Fatal(err)
}
}
func withBerlin(t *testing.T) {
t.Helper()
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Fatal(err)
}
prev := time.Local
time.Local = loc
t.Cleanup(func() { time.Local = prev })
}
func day(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
func summaries(evs []calEvent) []string {
var out []string
for _, ev := range evs {
out = append(out, ev.Start.Format("2006-01-02 15:04")+" "+ev.Summary)
}
return out
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(want, "\n "))
}
}
func TestCalendarSingleAndAllDay(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
writeCal(t, dir, "P",
`UID:a
SUMMARY:Zahnarzt
DTSTART;TZID=Europe/Berlin:20261002T090000
DTEND;TZID=Europe/Berlin:20261002T100000`,
`UID:b
SUMMARY:Urlaub
DTSTART;VALUE=DATE:20261001
DTEND;VALUE=DATE:20261004`,
`UID:c
SUMMARY:UTC call
DTSTART:20261002T160000Z
DURATION:PT1H`,
)
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
assertStrings(t, got, []string{
"2026-10-01 00:00 Urlaub",
"2026-10-02 09:00 Zahnarzt",
"2026-10-02 18:00 UTC call",
})
// DTEND of all-day events is exclusive.
if got := calendarOccurrences(dir, []string{"P"}, day(2026, 10, 4), day(2026, 10, 5)); len(got) != 0 {
t.Fatalf("all-day event leaked past DTEND: %v", summaries(got))
}
}
func TestCalendarRecurrence(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
writeCal(t, dir, "M",
`UID:backup
SUMMARY:Backup
DTSTART;TZID=Europe/Berlin:20260907T030000
DTEND;TZID=Europe/Berlin:20260907T040000
RRULE:FREQ=WEEKLY;BYDAY=MO
EXDATE;TZID=Europe/Berlin:20260914T030000,20260921T030000`,
`UID:backup
RECURRENCE-ID;TZID=Europe/Berlin:20260928T030000
SUMMARY:Backup (moved)
DTSTART;TZID=Europe/Berlin:20260929T050000
DTEND;TZID=Europe/Berlin:20260929T060000`,
`UID:backup
RECURRENCE-ID;TZID=Europe/Berlin:20261005T030000
DTSTART;TZID=Europe/Berlin:20261005T030000
STATUS:CANCELLED`,
`UID:sheets
SUMMARY:Bettwäsche
DTSTART;VALUE=DATE:20260905
RRULE:FREQ=WEEKLY;INTERVAL=2`,
)
got := summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 9, 1), day(2026, 10, 20)))
assertStrings(t, got, []string{
"2026-09-05 00:00 Bettwäsche",
"2026-09-07 03:00 Backup",
"2026-09-19 00:00 Bettwäsche",
"2026-09-29 05:00 Backup (moved)",
"2026-10-03 00:00 Bettwäsche",
"2026-10-12 03:00 Backup",
"2026-10-17 00:00 Bettwäsche",
"2026-10-19 03:00 Backup",
})
// Wall-clock time survives the DST change on 2026-10-25.
got = summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 10, 26), day(2026, 10, 27)))
assertStrings(t, got, []string{"2026-10-26 03:00 Backup"})
evs := calendarOccurrences(dir, []string{"M"}, day(2026, 9, 5), day(2026, 9, 8))
if evs[0].Series == nil || evs[0].Series.Rule != "every 2 weeks" {
t.Fatalf("sheets rule = %+v", evs[0].Series)
}
if evs[1].Series == nil || evs[1].Series.Rule != "weekly (Mo)" {
t.Fatalf("backup rule = %+v", evs[1].Series)
}
}
func TestCalendarUnknownTZIDFallsBack(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
writeCal(t, dir, "P",
`UID:x
SUMMARY:Windows zone
DTSTART;TZID=W. Europe Standard Time:20261002T090000
DTEND;TZID=W. Europe Standard Time:20261002T100000`,
`UID:y
SUMMARY:Prefixed zone
DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20261002T110000`,
)
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
assertStrings(t, got, []string{
"2026-10-02 09:00 Windows zone",
"2026-10-02 11:00 Prefixed zone",
})
}
func TestCalendarDayLines(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
prev := calendarsDir
calendarsDir = dir
t.Cleanup(func() { calendarsDir = prev })
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
[]byte("type = calendars\ndiary = Personal, Feiertage\nweight = Gewicht\nmaintenance = Maintainance\n"), 0644); err != nil {
t.Fatal(err)
}
writeCal(t, dir, "Personal",
`UID:1
SUMMARY:Kino
DTSTART;TZID=Europe/Berlin:20261003T193000`,
`UID:2
SUMMARY:Brunch\, Anna
DTSTART;TZID=Europe/Berlin:20261003T100000`,
)
writeCal(t, dir, "Feiertage",
`UID:3
SUMMARY:Tag der Deutschen Einheit
DTSTART;VALUE=DATE:20261003`,
)
writeCal(t, dir, "Gewicht",
`UID:4
SUMMARY:95.9 KG
DTSTART;VALUE=DATE:20261003
DTSTAMP:20261003T060000Z`,
`UID:5
SUMMARY:95,6kg
DTSTART;VALUE=DATE:20261003
DTSTAMP:20261003T070000Z`,
`UID:6
SUMMARY:not a weight
DTSTART;VALUE=DATE:20261004`,
)
writeCal(t, dir, "Maintainance",
`UID:7
SUMMARY:Backup
DTSTART;VALUE=DATE:20261003`,
)
lines := calendarDayLines(day(2026, 10, 1), day(2026, 10, 10))
want := calDayLines{
Events: "Tag der Deutschen Einheit, 10:00 Brunch, Anna, 19:30 Kino",
Weight: "95,6 kg",
}
if lines["2026-10-03"] != want {
t.Fatalf("got %+v, want %+v", lines["2026-10-03"], want)
}
if _, ok := lines["2026-10-04"]; ok {
t.Fatalf("unparsable weight produced a line: %+v", lines["2026-10-04"])
}
}
func TestCalendarSync(t *testing.T) {
dir := t.TempDir()
prev := calendarsDir
calendarsDir = dir
t.Cleanup(func() { calendarsDir = prev })
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:95,6kg\r\nDTSTART;VALUE=DATE:20261003\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, _ := r.BasicAuth(); u != "me" || p != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav/calendars/me/":
w.WriteHeader(http.StatusMultiStatus)
io.WriteString(w, `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/dav/calendars/me/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/dav/calendars/me/gewicht/</d:href><d:propstat><d:prop><d:displayname>Gewicht</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/dav/calendars/me/broken/</d:href><d:propstat><d:prop><d:displayname>Broken/Name</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/dav/calendars/me/inbox/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/><cal:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
</d:multistatus>`)
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/gewicht/" && r.URL.RawQuery == "export":
io.WriteString(w, ics)
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/broken/":
io.WriteString(w, "<html>login</html>")
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
// An existing file must survive a broken download.
if err := os.WriteFile(filepath.Join(dir, "Broken_Name.ics"), []byte("old"), 0644); err != nil {
t.Fatal(err)
}
results, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me", CaldavUser: "me", CaldavPass: "secret"})
if err != nil {
t.Fatal(err)
}
if len(results) != 2 || results["Gewicht"].Error != "" || results["Broken_Name"].Error == "" {
t.Fatalf("results = %+v", results)
}
if got, _ := os.ReadFile(filepath.Join(dir, "Gewicht.ics")); string(got) != ics {
t.Fatalf("Gewicht.ics = %q", got)
}
if got, _ := os.ReadFile(filepath.Join(dir, "Broken_Name.ics")); string(got) != "old" {
t.Fatalf("broken download replaced the file: %q", got)
}
if _, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me/", CaldavUser: "me", CaldavPass: "wrong"}); err == nil || !strings.Contains(err.Error(), "authentication") {
t.Fatalf("wrong password: err = %v", err)
}
}
func TestHasDotSegment(t *testing.T) {
for p, want := range map[string]bool{
"/Calendars/.page-settings": true,
"/.auth-key": true,
"/a/.hidden/b.txt": true,
"/Calendars/Personal.ics": false,
"/a/b.c/": false,
"/a/../.auth-key": true,
"/": false,
} {
if got := hasDotSegment(p); got != want {
t.Errorf("hasDotSegment(%q) = %v, want %v", p, got, want)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"bytes"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func init() {
pageTypeHandlers = append(pageTypeHandlers, &calendarsHandler{})
}
// calendarsHandler renders folders whose .page-settings declares
// type = calendars: the maintenance table and the sync status. Markdown
// content and the listing stay visible, as in the fitness folder.
type calendarsHandler struct{}
func (c *calendarsHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
return "", false
}
func (c *calendarsHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath)
if s == nil || s.Type != "calendars" {
return nil
}
return &specialPage{Content: renderCalendarsPage(fsPath), SuppressTOC: true}
}
type calFileRow struct {
Name string
Roles string
Events string
Updated string // file modification time
Error string // last sync or parse error
}
type calPageData struct {
HasMaintenance bool
Maintenance []maintSeriesRow
Calendars []calFileRow
// Sync is only shown for the configured calendars folder.
IsSyncDir bool
SyncEnabled bool
LastRun string
LastError string
}
var calPageTmpl = template.Must(template.ParseFS(assets, "assets/calendar/page.html"))
func renderCalendarsPage(dir string) template.HTML {
s := readCalendarSettings(dir)
data := calPageData{
HasMaintenance: len(s.Maintenance) > 0,
Maintenance: maintenanceSeries(dir, s.Maintenance, time.Now()),
IsSyncDir: calendarsDir != "" && dir == calendarsDir,
SyncEnabled: s.CaldavURL != "",
}
var results map[string]calSyncResult
if data.IsSyncDir {
var lastRun time.Time
lastRun, data.LastError, results = calSyncSnapshot()
if !lastRun.IsZero() {
data.LastRun = lastRun.Format("2006-01-02 15:04")
}
}
roles := map[string][]string{}
for role, names := range map[string][]string{"diary": s.Diary, "weight": s.Weight, "maintenance": s.Maintenance} {
for _, n := range names {
roles[n] = append(roles[n], role)
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
name := e.Name()
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".ics") {
continue
}
name = strings.TrimSuffix(name, filepath.Ext(name))
sort.Strings(roles[name])
row := calFileRow{Name: name, Roles: strings.Join(roles[name], ", "), Error: results[name].Error}
if info, err := e.Info(); err == nil {
row.Updated = info.ModTime().Format("2006-01-02 15:04")
}
if f := loadCalendar(filepath.Join(dir, e.Name())); f != nil {
row.Events = strconv.Itoa(f.events)
} else if row.Error == "" {
row.Error = "cannot parse file"
}
data.Calendars = append(data.Calendars, row)
delete(results, name)
}
// Calendars whose download failed before a file ever existed.
for name, r := range results {
if r.Error != "" {
data.Calendars = append(data.Calendars, calFileRow{Name: name, Error: r.Error})
}
}
sort.SliceStable(data.Calendars, func(i, j int) bool { return data.Calendars[i].Name < data.Calendars[j].Name })
var buf bytes.Buffer
if err := calPageTmpl.Execute(&buf, data); err != nil {
log.Printf("calendars page template: %v", err)
return ""
}
return template.HTML(buf.String())
}
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
"sync"
"time"
)
// The calendar sync mirrors every calendar of a CalDAV calendar home (e.g.
// Nextcloud's /remote.php/dav/calendars/<user>/) into calendarsDir as
// <display name>.ics. It runs in the background only: page renders read the
// local files and never touch the network. A failed run leaves the previous
// files in place.
// calSyncMaxBody bounds a single calendar download.
const calSyncMaxBody = 64 << 20
var calSyncClient = &http.Client{Timeout: 60 * time.Second}
// calSyncResult is the outcome of the last run for one calendar.
type calSyncResult struct {
Updated time.Time // last successful download
Error string
}
var calSync struct {
run sync.Mutex // serializes runs
mu sync.Mutex // guards the fields below
lastRun time.Time
lastError string // config/discovery error of the last run
results map[string]calSyncResult
}
// startCalendarSync checks the settings once a minute and syncs when the
// interval since the last run has passed. The first check happens right
// away. Settings are read every time, so enabling the sync or changing the
// interval needs no restart.
func startCalendarSync() {
go func() {
for {
s := readCalendarSettings(calendarsDir)
if s.CaldavURL != "" {
calSync.mu.Lock()
last := calSync.lastRun
calSync.mu.Unlock()
if last.IsZero() || (s.SyncInterval > 0 && time.Since(last) >= s.SyncInterval) {
runCalendarSync()
}
}
time.Sleep(time.Minute)
}
}()
}
// runCalendarSync performs one sync run and records the outcome.
func runCalendarSync() {
calSync.run.Lock()
defer calSync.run.Unlock()
results, err := syncCalendars(readCalendarSettings(calendarsDir))
calSync.mu.Lock()
defer calSync.mu.Unlock()
calSync.lastRun = time.Now()
calSync.lastError = ""
if err != nil {
calSync.lastError = err.Error()
log.Printf("calendar sync: %v", err)
}
if calSync.results == nil {
calSync.results = map[string]calSyncResult{}
}
for name, r := range results {
prev := calSync.results[name]
if r.Updated.IsZero() {
r.Updated = prev.Updated
}
calSync.results[name] = r
if r.Error != "" {
log.Printf("calendar sync %s: %s", name, r.Error)
}
}
}
// calSyncSnapshot returns a copy of the sync status for display.
func calSyncSnapshot() (lastRun time.Time, lastError string, results map[string]calSyncResult) {
calSync.mu.Lock()
defer calSync.mu.Unlock()
results = make(map[string]calSyncResult, len(calSync.results))
for k, v := range calSync.results {
results[k] = v
}
return calSync.lastRun, calSync.lastError, results
}
// handleCalendarSync runs a sync now (POST ?calsync on the calendars page)
// and returns to the page.
func (h *handler) handleCalendarSync(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
if calendarsDir == "" || fsPath != calendarsDir {
http.Error(w, "not the calendars folder", http.StatusBadRequest)
return
}
if readCalendarSettings(calendarsDir).CaldavURL == "" {
http.Error(w, "sync is not configured (caldav-url)", http.StatusBadRequest)
return
}
runCalendarSync()
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
// syncCalendars downloads every calendar of the calendar home. The returned
// map has one entry per calendar that was attempted; err is set when nothing
// could be attempted (bad settings, discovery failed).
func syncCalendars(s calendarSettings) (map[string]calSyncResult, error) {
home, err := url.Parse(s.CaldavURL)
if err != nil || (home.Scheme != "http" && home.Scheme != "https") {
return nil, fmt.Errorf("invalid caldav-url %q", s.CaldavURL)
}
if !strings.HasSuffix(home.Path, "/") {
home.Path += "/"
}
cals, err := davDiscoverCalendars(home, s)
if err != nil {
return nil, err
}
results := map[string]calSyncResult{}
used := map[string]bool{}
for _, c := range cals {
name := calendarFileName(c.name, c.href)
if used[strings.ToLower(name)] {
name += " (" + path.Base(strings.TrimSuffix(c.href, "/")) + ")"
}
used[strings.ToLower(name)] = true
if err := syncOneCalendar(home, c.href, name, s); err != nil {
results[name] = calSyncResult{Error: err.Error()}
continue
}
results[name] = calSyncResult{Updated: time.Now()}
}
return results, nil
}
// syncOneCalendar downloads one calendar via Nextcloud's ?export and writes
// it when the content changed. Invalid downloads (HTML error pages, broken
// iCalendar) never replace the existing file.
func syncOneCalendar(home *url.URL, href, name string, s calendarSettings) error {
u, err := home.Parse(href)
if err != nil {
return err
}
u.RawQuery = "export"
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
req.Header.Set("Accept", "text/calendar")
resp, err := calSyncClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download: %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, calSyncMaxBody+1))
if err != nil {
return err
}
if len(data) > calSyncMaxBody {
return errors.New("download: calendar larger than 64 MB")
}
if _, err := parseCalendar(name+".ics", data); err != nil {
return fmt.Errorf("invalid calendar data: %v", err)
}
p := calendarPath(calendarsDir, name)
if p == "" {
return fmt.Errorf("unusable file name %q", name)
}
if old, err := os.ReadFile(p); err == nil && bytes.Equal(old, data) {
return nil
}
if err := os.MkdirAll(calendarsDir, 0755); err != nil {
return err
}
return writeFileAtomic(p, data, 0644)
}
// calendarFileName turns a display name into a safe file basename. Falls back
// to the last segment of the calendar's href.
func calendarFileName(display, href string) string {
clean := strings.Map(func(r rune) rune {
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
return '_'
}
return r
}, display)
clean = strings.TrimLeft(strings.TrimSpace(clean), ".")
if clean == "" {
clean = strings.TrimLeft(path.Base(strings.TrimSuffix(href, "/")), ".")
}
if clean == "" {
clean = "calendar"
}
return clean
}
// === Discovery ===
type davCalendar struct {
href string
name string
}
const davPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>`
type davMultistatus struct {
Responses []struct {
Href string `xml:"DAV: href"`
Propstats []struct {
Status string `xml:"DAV: status"`
Prop struct {
DisplayName string `xml:"DAV: displayname"`
ResourceType struct {
Types []struct {
XMLName xml.Name
} `xml:",any"`
} `xml:"DAV: resourcetype"`
} `xml:"DAV: prop"`
} `xml:"DAV: propstat"`
} `xml:"DAV: response"`
}
// davDiscoverCalendars lists the calendar collections directly below home
// with one PROPFIND (Depth 1).
func davDiscoverCalendars(home *url.URL, s calendarSettings) ([]davCalendar, error) {
req, err := http.NewRequest("PROPFIND", home.String(), strings.NewReader(davPropfindBody))
if err != nil {
return nil, err
}
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := calSyncClient.Do(req)
if err != nil {
return nil, fmt.Errorf("discovery: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, errors.New("discovery: authentication failed (check caldav-user / caldav-pass)")
}
if resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("discovery: %s", resp.Status)
}
var ms davMultistatus
if err := xml.NewDecoder(io.LimitReader(resp.Body, calSyncMaxBody)).Decode(&ms); err != nil {
return nil, fmt.Errorf("discovery: %v", err)
}
var out []davCalendar
for _, r := range ms.Responses {
for _, ps := range r.Propstats {
if !strings.Contains(ps.Status, " 200 ") {
continue
}
isCalendar := false
for _, t := range ps.Prop.ResourceType.Types {
if t.XMLName.Space == "urn:ietf:params:xml:ns:caldav" && t.XMLName.Local == "calendar" {
isCalendar = true
}
}
if isCalendar {
out = append(out, davCalendar{href: r.Href, name: strings.TrimSpace(ps.Prop.DisplayName)})
}
}
}
if len(out) == 0 {
return nil, errors.New("discovery: no calendars found at caldav-url")
}
return out, nil
}
+26 -1
View File
@@ -335,7 +335,7 @@ type calendarData struct {
Years []calYear
}
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now()
@@ -533,6 +533,8 @@ type diarySection struct {
EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto
Events string // day's calendar events, comma-separated (level 3 only)
Weight string // day's weight from the weight calendar (level 3 only)
}
type diaryContentData struct {
@@ -623,6 +625,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL)
attachCalendarLines(out, year)
return renderDiaryContent(out)
}
@@ -746,6 +749,28 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
return out
}
// attachCalendarLines fills the events and weight lines of every day section
// (real or virtual) from the calendars' diary and weight roles.
func attachCalendarLines(sections []diarySection, year int) {
lines := calendarDayLines(
time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local),
time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.Local))
if len(lines) == 0 {
return
}
for i := range sections {
s := &sections[i]
if s.Level != 3 {
continue
}
if _, _, _, ok := parseISODate(s.ID); !ok {
continue
}
l := lines[s.ID[:10]]
s.Events, s.Weight = l.Events, l.Weight
}
}
// 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.
+1 -1
View File
@@ -92,7 +92,7 @@ func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, e
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
w.WriteString(`" loading="lazy"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
+171 -171
View File
@@ -2,14 +2,12 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"net/url"
"sort"
"strconv"
"strings"
@@ -20,10 +18,6 @@ func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
}
// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"
type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders
@@ -34,16 +28,15 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request)
// handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so
// the user can verify an uploaded export arrived.
// the folder can still hold notes.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" {
return nil
}
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
q := r.URL.Query()
return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
SuppressTOC: true,
}
}
@@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string {
return fallback
}
// fitnessView is one chart's view state from the query string: ?<param>=<range>
// and ?<param>-absolute=1 to start the y axis at 0.
type fitnessView struct {
Range string
Absolute bool
}
func chartViewFrom(q url.Values, param, fallback string) fitnessView {
return fitnessView{
Range: validFitnessRange(q.Get(param), fallback),
Absolute: q.Get(param+"-absolute") == "1",
}
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
@@ -81,95 +88,6 @@ func rangeMonths(v string) int {
return 0
}
// === Waistline export parsing ===
// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
val float64
ok bool
}
func (n *wlNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
n.val = v
n.ok = true
return nil
}
// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
if len(s) < 10 {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return time.Time{}, false
}
return t, true
}
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
var s struct {
Goals map[string]json.RawMessage `json:"goals"`
}
if json.Unmarshal(settings, &s) != nil {
return 0, false
}
var g struct {
GoalList []struct {
Goal []wlNum `json:"goal"`
} `json:"goal-list"`
}
if json.Unmarshal(s.Goals[key], &g) != nil {
return 0, false
}
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
return 0, false
}
n := g.GoalList[0].Goal[0]
return n.val, n.ok
}
func exportWeightUnit(settings json.RawMessage) string {
var s struct {
Units struct {
Weight string `json:"weight"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
return s.Units.Weight
}
return "kg"
}
// === Series extraction ===
type weightPoint struct {
@@ -178,26 +96,34 @@ type weightPoint struct {
days int // >0: weekly mean over this many measured days
}
// extractWeights computes the weight series from the export: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
// one wins.
func extractWeights(ex *wlExport) []weightPoint {
byDate := map[time.Time]float64{}
for _, e := range ex.Diary {
day, ok := exportDate(e.DateTime)
if !ok || !e.Stats.Weight.ok {
// fitnessWeights reads the whole weight calendar up to today: one point per
// day, sorted by date. Dates are UTC midnights to match the chart's today.
func fitnessWeights(today time.Time) []weightPoint {
byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
weights := make([]weightPoint, 0, len(byDay))
for key, w := range byDay {
d, err := time.Parse("2006-01-02", key)
v, err2 := strconv.ParseFloat(w, 64)
if err != nil || err2 != nil {
continue
}
byDate[day] = e.Stats.Weight.val
}
weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate {
weights = append(weights, weightPoint{date: d, value: v})
}
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights
}
// weightGoal reads the optional weight-goal key from the fitness folder's
// .page-settings.
func weightGoal(fsPath string) (float64, bool) {
v, ok := readSettingsFile(fsPath)["weight-goal"]
if !ok {
return 0, false
}
g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64)
return g, err == nil
}
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
@@ -280,8 +206,9 @@ type fitnessTickVM struct {
Pos, Label, Anchor string
}
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
type fitnessDotVM struct {
X, Y, Title string
X, Y, Date, Value string
}
type fitnessGoalVM struct {
@@ -289,10 +216,11 @@ type fitnessGoalVM struct {
}
type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Empty bool
Title string
Param string
Options []fitnessOptVM
Absolute bool
Empty bool
ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string
@@ -301,6 +229,7 @@ type fitnessChartVM struct {
Goal *fitnessGoalVM
Lines []string // polyline points attributes
Dots []fitnessDotVM
Dense bool // too many points to draw markers
}
type fitnessDashVM struct {
@@ -308,13 +237,13 @@ type fitnessDashVM struct {
Charts []fitnessChartVM
}
func newChartVM(title, param, sel string) fitnessChartVM {
func newChartVM(title, param string, view fitnessView) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range}
}
return fitnessChartVM{
Title: title, Param: param, Options: opts,
Title: title, Param: param, Options: opts, Absolute: view.Absolute,
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
@@ -323,7 +252,7 @@ func newChartVM(title, param, sel string) fitnessChartVM {
}
// yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
// (5 kg guides on weight charts, 20 kg when the axis starts at 0).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
@@ -346,12 +275,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
// buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel)
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, view)
points := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
if m := rangeMonths(view.Range); m > 0 {
x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0)
if len(points) == 0 {
@@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
pad = 1
}
sc := chartScale{x0, x1, lo - pad, hi + pad}
yStep := 5.0
if view.Absolute {
sc.y0 = 0
sc.y1 = hi + hi*0.05
yStep = 20
}
vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc, 4)
vm.YTicks = yTickVMs(sc, yStep)
vm.XTicks = timeXTicks(sc)
// One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover
// <title>; on dense ranges they are dropped and the bare line stays
// legible. A single point in range renders as a dot.
// measurement do not break the line. Every point also gets a marker that
// feeds the hover tooltip; on dense ranges the markers are hidden and the
// bare line stays legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
if p.days > 0 {
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
d := fitnessDotVM{
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
Date: p.date.Format("Mon 2006-01-02"),
Value: svgNum(p.value) + " " + unit,
}
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
if p.days > 0 {
d.Date = "Week of " + p.date.Format("2006-01-02")
d.Value += fmt.Sprintf(" · %d days", p.days)
}
return d
}
if len(points) >= 2 {
var b strings.Builder
@@ -419,11 +358,10 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
}
vm.Lines = append(vm.Lines, b.String())
}
if len(points) <= 100 {
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
vm.Dense = len(points) > 100
if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -441,34 +379,101 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
return out
}
// timeXTicks places n+1 evenly spaced date labels across a continuous time
// axis; the last label is end-anchored so it stays inside the viewBox.
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
span := sc.x1.Sub(sc.x0)
var out []fitnessTickVM
prev := ""
for i := 0; i <= n; i++ {
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
label := t.Format("2006-01-02")
if label == prev {
continue
// xTickStep is a calendar-aligned spacing for date labels.
type xTickStep struct {
days, months int
label func(t time.Time, first bool) string
}
var xTickSteps = []xTickStep{
{days: 1, label: dayLabel},
{days: 7, label: dayLabel},
{days: 14, label: dayLabel},
{months: 1, label: monthLabel},
{months: 3, label: monthLabel},
{months: 6, label: monthLabel},
{months: 12, label: yearLabel},
{months: 24, label: yearLabel},
{months: 60, label: yearLabel},
}
func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") }
// monthLabel names the year only where it changes, so a year-long axis
// reads "Oct 2025, Jan 2026, Apr, Jul".
func monthLabel(t time.Time, first bool) string {
if first || t.Month() == time.January {
return t.Format("Jan 2006")
}
return t.Format("Jan")
}
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
// maxXTicks bounds the label count so labels never collide on the axis.
const maxXTicks = 5
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
// month starts or new years, whichever is the finest step that stays within
// maxXTicks. Labels near the plot edges are anchored inward so they stay
// inside the viewBox.
func timeXTicks(sc chartScale) []fitnessTickVM {
var ticks []time.Time
var step xTickStep
for _, step = range xTickSteps {
ticks = alignedTicks(sc.x0, sc.x1, step)
if len(ticks) <= maxXTicks {
break
}
prev = label
}
out := make([]fitnessTickVM, 0, len(ticks))
for i, t := range ticks {
x := sc.x(t)
anchor := "middle"
if i == n {
switch {
case x-chartLeft < 24:
anchor = "start"
case chartW-chartRight-x < 24:
anchor = "end"
}
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor})
}
return out
}
// alignedTicks lists every boundary of step within [from, to]. Week steps
// start on Mondays; month and year steps on multiples of the step (Jan/Apr/
// Jul/Oct for quarters, even years for two-year steps).
func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
var t time.Time
if step.days > 0 {
t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
if step.days > 1 {
t = mondayOf(t)
}
} else {
months := from.Year()*12 + int(from.Month()) - 1
months -= months % step.months
t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC)
}
var out []time.Time
for ; !t.After(to); t = t.AddDate(0, step.months, step.days) {
if !t.Before(from) {
out = append(out, t)
}
if len(out) > maxXTicks {
break
}
}
return out
}
// === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err)
@@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
return template.HTML(buf.String())
}
// buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
if err != nil {
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
}
var ex wlExport
if err := json.Unmarshal(raw, &ex); err != nil {
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
// buildFitnessDash reads the weight calendar per request (the calendar store
// caches parsed files). A missing calendar setup becomes an inline notice;
// the page itself always renders.
func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex)
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
unit := exportWeightUnit(ex.Settings)
weights := fitnessWeights(today)
wGoal, hasWGoal := weightGoal(fsPath)
const unit = "kg"
return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
buildWeightChart(weights, wGoal, hasWGoal, weightView,
"weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
"weekly", "Weekly average weight ("+unit+")", unit, today),
}}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"os"
"strings"
"path/filepath"
"testing"
"time"
)
func TestFitnessWeights(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
prev := calendarsDir
calendarsDir = dir
t.Cleanup(func() { calendarsDir = prev })
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
[]byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
t.Fatal(err)
}
writeCal(t, dir, "Gewicht",
`UID:1
SUMMARY:96 kg
DTSTART;VALUE=DATE:20261001`,
`UID:2
SUMMARY:95,6kg
DTSTART;VALUE=DATE:20261003`,
`UID:3
SUMMARY:not a weight
DTSTART;VALUE=DATE:20261004`,
`UID:4
SUMMARY:90 kg
DTSTART;VALUE=DATE:20261010`,
)
today := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
got := fitnessWeights(today)
want := []weightPoint{
{date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), value: 96},
{date: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), value: 95.6},
}
if len(got) != len(want) {
t.Fatalf("got %+v, want %+v", got, want)
}
for i := range want {
if !got[i].date.Equal(want[i].date) || got[i].value != want[i].value {
t.Fatalf("point %d: got %+v, want %+v", i, got[i], want[i])
}
}
}
func TestTimeXTicks(t *testing.T) {
d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) }
for _, c := range []struct {
from, to time.Time
want string
}{
{d(2022, 11, 28), d(2026, 9, 21), "2023 2024 2025 2026"},
{d(2025, 9, 27), d(2026, 9, 27), "Oct 2025|Jan 2026|Apr|Jul"},
{d(2026, 6, 27), d(2026, 9, 27), "Jul 2026|Aug|Sep"},
{d(2026, 8, 27), d(2026, 9, 27), "31 Aug|7 Sep|14 Sep|21 Sep"},
} {
var got []string
for _, tk := range timeXTicks(chartScale{x0: c.from, x1: c.to}) {
got = append(got, tk.Label)
}
sep := " "
if strings.Contains(c.want, "|") {
sep = "|"
}
if g := strings.Join(got, sep); g != c.want {
t.Errorf("%s..%s: got %q, want %q", c.from.Format("2006-01-02"), c.to.Format("2006-01-02"), g, c.want)
}
}
}
+5 -1
View File
@@ -2,4 +2,8 @@ module datascape
go 1.26.1
require github.com/yuin/goldmark v1.8.2
require (
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
github.com/teambition/rrule-go v1.8.2
github.com/yuin/goldmark v1.8.2
)
+4
View File
@@ -1,2 +1,6 @@
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+87 -49
View File
@@ -2,12 +2,9 @@ package main
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"flag"
"html/template"
"io/fs"
"log"
"net/http"
"net/url"
@@ -17,31 +14,20 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
)
//go:embed assets
var assets embed.FS
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
// appended as ?v=… to its <script> src. The bundle is served immutable under a
// stable filename, so without this query a rebuilt bundle would never reach a
// client that already cached the old one (this is the editor cache-bust knob).
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
func hashAsset(name string) string {
b, err := assets.ReadFile(name)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:12]
}
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
// tmplFuncs is shared by every layout-based template: asset links versioned
// static files (see static.go); the layout header renders the maintenance
// widget.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
"asset": assetURL,
"fileIcon": fileIcon,
"icon": readIcon,
"maintenanceWidget": maintenanceWidget,
}
var (
@@ -88,8 +74,18 @@ func main() {
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)")
tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)")
flag.Parse()
if *tz != "" {
loc, err := time.LoadLocation(*tz)
if err != nil {
log.Fatalf("-tz: %v", err)
}
time.Local = loc
}
root, err := filepath.Abs(*wikiDir)
if err != nil {
log.Fatal(err)
@@ -108,20 +104,22 @@ func main() {
initMarkdown()
if *calendars != "" {
calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars))
rel, err := filepath.Rel(root, calendarsDir)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
log.Fatalf("-calendars must be a folder inside -dir")
}
calendarsURL = "/" + filepath.ToSlash(rel) + "/"
}
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
log.Fatal(err)
}
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
static.ServeHTTP(w, r)
}))
http.Handle("/_/", serveStatic())
http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest)
@@ -158,6 +156,10 @@ func main() {
}(*reindexInterval)
}
if calendarsDir != "" {
startCalendarSync()
}
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil))
}
@@ -173,14 +175,21 @@ type reqStartKeyT struct{}
var reqStartKey = reqStartKeyT{}
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
func elapsedMS(r *http.Request) int64 {
// requestStart returns when the request entered ServeHTTP, or now if unknown.
func requestStart(r *http.Request) time.Time {
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
return time.Since(start).Milliseconds()
return start
}
return 0
return time.Now()
}
// renderTimer is embedded in template data. The footer calls RenderMS while
// the template executes, so the time includes the page's own rendering (and
// the header's maintenance widget), not just the work before it.
type renderTimer struct{ start time.Time }
func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() }
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
@@ -188,6 +197,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Dotfiles are never served: .page-settings can hold the CalDAV
// password and .auth-key signs sessions. Nothing links to them (listing,
// tree and search hide them already).
if hasDotSegment(r.URL.Path) {
http.NotFound(w, r)
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r)
return
@@ -255,6 +272,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// hasDotSegment reports whether any segment of the cleaned URL path starts
// with a dot.
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(path.Clean("/"+p), "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve
// against.
@@ -386,7 +414,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
if editMode {
t = editTmpl
}
data.RenderMS = elapsedMS(r)
data.renderTimer = renderTimer{requestStart(r)}
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
@@ -414,6 +442,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath)
return
}
if query.Has("calsync") {
h.handleCalendarSync(w, r, urlPath, fsPath)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -525,12 +557,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
// Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings {
kv := readSettingsFile(dir)
if kv == nil {
return nil
}
// Unknown or missing values fall back to the defaults.
return &pageSettings{
Type: kv["type"],
View: validateView(kv["view"]),
Sort: validateSort(kv["sort"]),
Order: validateOrder(kv["order"]),
}
}
// readSettingsFile returns every key of dir's .page-settings (last one wins),
// or nil if the file does not exist. Other features (e.g. the calendars
// folder) keep their own keys in the same file.
func readSettingsFile(dir string) map[string]string {
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil {
return nil
}
// Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
kv := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -540,17 +588,7 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 {
continue
}
value := strings.TrimSpace(parts[1])
switch strings.TrimSpace(parts[0]) {
case "type":
s.Type = value
case "view":
s.View = validateView(value)
case "sort":
s.Sort = validateSort(value)
case "order":
s.Order = validateOrder(value)
}
kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return s
return kv
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"bytes"
"html/template"
"log"
"sort"
"time"
)
// calendarsURL is the page URL of calendarsDir ("/Calendars/").
var calendarsURL string
// maintenanceHorizon is how many days after today the header widget lists.
const maintenanceHorizon = 7
type maintItem struct {
When string // "03:00" today; "Sa 27.09." or "Sa 27.09. 03:00" later
Summary string
}
type maintWidgetData struct {
Count int // occurrences today (the badge)
Today []maintItem
Upcoming []maintItem
PageURL string
}
var maintWidgetTmpl = template.Must(template.New("widget.html").
Funcs(template.FuncMap{"icon": readIcon}).
ParseFS(assets, "assets/calendar/widget.html"))
// maintenanceWidget renders the header chip and its dropdown. It is a
// template func so layout.html can call it without every page's data struct
// carrying it. Empty when no maintenance calendar is configured.
func maintenanceWidget() template.HTML {
if calendarsDir == "" {
return ""
}
names := readCalendarSettings(calendarsDir).Maintenance
if len(names) == 0 {
return ""
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
tomorrow := today.AddDate(0, 0, 1)
data := maintWidgetData{PageURL: calendarsURL}
for _, ev := range calendarOccurrences(calendarsDir, names, today, today.AddDate(0, 0, maintenanceHorizon+1)) {
// Anything that started before tomorrow is due today, including
// multi-day events still running.
if ev.Start.Before(tomorrow) {
item := maintItem{Summary: ev.Summary}
if !ev.AllDay && !ev.Start.Before(today) {
item.When = ev.Start.Format("15:04")
}
data.Today = append(data.Today, item)
continue
}
when := calDayLabel(ev.Start)
if !ev.AllDay {
when += " " + ev.Start.Format("15:04")
}
data.Upcoming = append(data.Upcoming, maintItem{When: when, Summary: ev.Summary})
}
data.Count = len(data.Today)
var buf bytes.Buffer
if err := maintWidgetTmpl.Execute(&buf, data); err != nil {
log.Printf("maintenance widget template: %v", err)
return ""
}
return template.HTML(buf.String())
}
// maintSeriesRow is one task in the calendars page's maintenance table.
type maintSeriesRow struct {
Summary string
Rule string
Last string // most recent occurrence before today
Next string // first occurrence today or later
next time.Time
}
// maintenanceWindow bounds the table's occurrence search, in days each way
// from today. A year covers yearly tasks.
const maintenanceWindow = 366
// maintenanceSeries lists every task of the named calendars in dir: one row
// per recurring series with its last and next date, plus upcoming single
// events. Sorted by next date; series without a next date come last.
func maintenanceSeries(dir string, names []string, now time.Time) []maintSeriesRow {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
evs := calendarOccurrences(dir, names, today.AddDate(0, 0, -maintenanceWindow), today.AddDate(0, 0, maintenanceWindow))
type key struct{ calendar, uid string }
rows := map[key]*maintSeriesRow{}
var order []*maintSeriesRow
for _, ev := range evs {
if ev.Series == nil {
if ev.Start.Before(today) {
continue
}
order = append(order, &maintSeriesRow{
Summary: ev.Summary,
Rule: "once",
Next: calDayLabel(ev.Start),
next: ev.Start,
})
continue
}
k := key{ev.Calendar, ev.Series.UID}
row := rows[k]
if row == nil {
row = &maintSeriesRow{Summary: ev.Series.Summary, Rule: ev.Series.Rule}
rows[k] = row
order = append(order, row)
}
// Occurrences arrive sorted by start: keep overwriting Last, take
// the first one from today on as Next.
if ev.Start.Before(today) {
row.Last = calDayLabel(ev.Start)
} else if row.next.IsZero() {
row.next = ev.Start
row.Next = calDayLabel(ev.Start)
}
}
out := make([]maintSeriesRow, 0, len(order))
for _, r := range order {
out = append(out, *r)
}
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i].next, out[j].next
if a.IsZero() != b.IsZero() {
return b.IsZero()
}
if !a.Equal(b) {
return a.Before(b)
}
return out[i].Summary < out[j].Summary
})
return out
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"time"
)
var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html"))
var quickAddTmpl = template.Must(template.New("quickadd.html").Funcs(assetFuncs).ParseFS(assets, "assets/quickadd.html"))
type quickAddData struct {
To, URL, Title string
+1 -1
View File
@@ -63,7 +63,7 @@ type pageData struct {
SpecialContent template.HTML
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
renderTimer
}
// Allowed values for the listing view settings. Unknown values in the file or
+15 -3
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"unicode"
"unicode/utf8"
)
// searchSectionCap bounds how many rows each results section renders. The
@@ -46,7 +47,7 @@ type searchPageData struct {
PageTotal int
FileTotal int
IndexBuiltAt time.Time
RenderMS int64
renderTimer
}
// indexEntry is the shared scoreable core of both folder and file index
@@ -132,7 +133,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
IndexBuiltAt: builtAt,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
data.renderTimer = renderTimer{requestStart(r)}
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("search template error: %v", err)
}
@@ -278,7 +279,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 {
best = 20
}
case fuzzy && levenshtein(w, qt) <= 2:
case fuzzy && withinTwoEdits(w, qt):
if best < 5 {
best = 5
}
@@ -575,6 +576,17 @@ func tokenize(s string) []string {
return tokens
}
// withinTwoEdits reports whether a and b are at most 2 edits apart. Lengths
// differing by more than 2 rule that out without running levenshtein, which
// skips nearly every word pair in the per-query scoring loop.
func withinTwoEdits(a, b string) bool {
d := utf8.RuneCountInString(a) - utf8.RuneCountInString(b)
if d < -2 || d > 2 {
return false
}
return levenshtein(a, b) <= 2
}
// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
)
// assetVersions maps each embedded asset (path relative to assets/, e.g.
// "page/toc.js") to a short hash of its content. Templates reference assets
// through assetURL, which appends the hash as ?v=…, so a versioned URL names
// one exact file content and can be cached forever; a rebuilt asset gets a
// new URL.
var assetVersions = hashAssets()
func hashAssets() map[string]string {
versions := map[string]string{}
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := assets.ReadFile(p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
versions[strings.TrimPrefix(p, "assets/")] = hex.EncodeToString(sum[:])[:12]
return nil
})
return versions
}
// assetGzip holds a gzip copy of every embedded asset that compresses well,
// keyed like assetVersions. Built once at startup so serving compressed costs
// no CPU per request; already-compressed files (fonts) are left out.
var assetGzip = gzipAssets()
func gzipAssets() map[string][]byte {
out := map[string][]byte{}
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := assets.ReadFile(p)
if err != nil {
return err
}
var buf bytes.Buffer
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
zw.Write(b)
zw.Close()
// Not worth a Content-Encoding round trip unless it saves ≥10%.
if buf.Len() < len(b)*9/10 {
out[strings.TrimPrefix(p, "assets/")] = buf.Bytes()
}
return nil
})
return out
}
// acceptsGzip reports whether the client accepts a gzip response body.
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
enc, q, _ := strings.Cut(strings.TrimSpace(enc), ";")
if strings.TrimSpace(enc) == "gzip" && strings.ReplaceAll(q, " ", "") != "q=0" {
return true
}
}
return false
}
// assetURL is the "asset" template func: {{asset "style.css"}} renders
// /_/style.css?v=<hash>. An unknown name fails the template so a typo or a
// removed file surfaces as a render error instead of a silent 404.
func assetURL(name string) (string, error) {
v, ok := assetVersions[name]
if !ok {
return "", fmt.Errorf("unknown asset %q", name)
}
return "/_/" + name + "?v=" + v, nil
}
// assetFuncs is for the standalone fragment templates that do not take the
// full tmplFuncs.
var assetFuncs = template.FuncMap{"asset": assetURL}
// serveStatic serves the embedded assets under /_/. Embedded files carry no
// modtime, so http.FileServer alone sends no validators and browsers refetch
// every asset on every page. Instead:
// - a URL carrying the current ?v= hash is immutable;
// - fonts are immutable too (referenced unversioned from style.css, never
// change);
// - anything else (unversioned or stale ?v=, e.g. icons from CSS) gets the
// content hash as ETag and revalidates, answered with 304 when unchanged.
//
// Assets with a precompressed copy (see assetGzip) are sent gzipped to
// clients that accept it; the gzip variant gets its own ETag.
func serveStatic() http.Handler {
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/_/")
v, ok := assetVersions[name]
gz, hasGz := assetGzip[name]
useGz := hasGz && acceptsGzip(r)
if hasGz {
w.Header().Set("Vary", "Accept-Encoding")
}
switch {
case ok && r.URL.Query().Get("v") == v, strings.HasPrefix(name, "fonts/"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case ok:
w.Header().Set("Cache-Control", "no-cache")
if useGz {
w.Header().Set("ETag", `"`+v+`-gz"`)
} else {
w.Header().Set("ETag", `"`+v+`"`)
}
}
if useGz {
w.Header().Set("Content-Encoding", "gzip")
// ServeContent derives Content-Type from the name's extension.
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(gz))
return
}
static.ServeHTTP(w, r)
})
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// Every {{asset "…"}} in the templates must name an embedded file, or the page
// fails to render.
func TestTemplateAssetsExist(t *testing.T) {
ref := regexp.MustCompile(`\{\{asset "([^"]+)"\}\}`)
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, _ := assets.ReadFile(p)
for _, m := range ref.FindAllSubmatch(b, -1) {
if _, err := assetURL(string(m[1])); err != nil {
t.Errorf("%s: %v", p, err)
}
}
return nil
})
}
func TestServeStaticCaching(t *testing.T) {
h := serveStatic()
get := func(url, ifNoneMatch string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, url, nil)
if ifNoneMatch != "" {
r.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
v := assetVersions["style.css"]
versioned, _ := assetURL("style.css")
if w := get(versioned, ""); w.Code != 200 || w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Errorf("versioned: %d %q", w.Code, w.Header().Get("Cache-Control"))
}
for _, u := range []string{"/_/style.css", "/_/style.css?v=stale"} {
w := get(u, "")
if w.Code != 200 || w.Header().Get("Cache-Control") != "no-cache" || w.Header().Get("ETag") != `"`+v+`"` {
t.Errorf("%s: %d %q %q", u, w.Code, w.Header().Get("Cache-Control"), w.Header().Get("ETag"))
}
}
if w := get("/_/style.css", `"`+v+`"`); w.Code != http.StatusNotModified {
t.Errorf("revalidate: got %d, want 304", w.Code)
}
if w := get("/_/fonts/IosevkaSlab.woff2", ""); w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Errorf("font: %q", w.Header().Get("Cache-Control"))
}
}
func TestServeStaticGzip(t *testing.T) {
h := serveStatic()
get := func(url, acceptEncoding, ifNoneMatch string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, url, nil)
if acceptEncoding != "" {
r.Header.Set("Accept-Encoding", acceptEncoding)
}
if ifNoneMatch != "" {
r.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
want, _ := assets.ReadFile("assets/style.css")
w := get("/_/style.css", "gzip, deflate, br", "")
if w.Code != 200 || w.Header().Get("Content-Encoding") != "gzip" || w.Header().Get("Vary") != "Accept-Encoding" {
t.Fatalf("gzip: %d %q %q", w.Code, w.Header().Get("Content-Encoding"), w.Header().Get("Vary"))
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("content-type: %q", ct)
}
zr, err := gzip.NewReader(w.Body)
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(zr)
if !bytes.Equal(got, want) {
t.Errorf("gzip body does not decompress to style.css")
}
etag := w.Header().Get("ETag")
if w := get("/_/style.css", "gzip", etag); w.Code != http.StatusNotModified {
t.Errorf("gzip revalidate: got %d, want 304", w.Code)
}
for _, ae := range []string{"", "identity", "gzip;q=0"} {
w := get("/_/style.css", ae, "")
if w.Header().Get("Content-Encoding") != "" || !bytes.Equal(w.Body.Bytes(), want) {
t.Errorf("Accept-Encoding %q: got encoding %q", ae, w.Header().Get("Content-Encoding"))
}
}
if w := get("/_/fonts/IosevkaSlab.woff2", "gzip", ""); w.Header().Get("Content-Encoding") != "" {
t.Errorf("font should not be re-compressed")
}
}
+41 -4
View File
@@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -48,8 +49,13 @@ var (
thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{}
digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
// photos otherwise decodes one full-size image per browser connection at
// once, which on the NAS means memory pressure and CPU thrash.
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
)
// digestEntry remembers the digest of a source file so repeated requests do
@@ -179,7 +185,10 @@ func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS s
return
}
if err := generateThumb(cacheFS, write); err != nil {
thumbGenSlots <- struct{}{}
err = generateThumb(cacheFS, write)
<-thumbGenSlots
if err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
@@ -199,6 +208,9 @@ func pathDigest(srcFS string, info os.FileInfo) string {
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse.
//
// Hits come from memory or, after a restart, from the on-disk index (see
// digestIndexPath), so an unchanged file is hashed only once ever.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock()
d, ok := digestCache[srcFS]
@@ -207,18 +219,43 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
return d.hex, nil, nil
}
indexFS := digestIndexPath(srcFS, info)
if b, err := os.ReadFile(indexFS); err == nil && len(b) == sha256.Size*2 {
h := string(b)
rememberDigest(srcFS, info, h)
return h, nil, nil
}
data, err := os.ReadFile(srcFS)
if err != nil {
return "", nil, err
}
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
rememberDigest(srcFS, info, h)
// Best effort: a failed write only costs a re-hash after the next restart.
if err := os.MkdirAll(filepath.Dir(indexFS), 0755); err == nil {
if err := writeFileAtomic(indexFS, []byte(h), 0644); err != nil {
log.Printf("thumb digest index %s: %v", indexFS, err)
}
}
return h, data, nil
}
// digestIndexPath is where the content digest of srcFS is persisted, keyed on
// path + mtime + size like pathDigest, so overwriting the file misses and
// re-hashes. Entries for changed or deleted files are left behind; each is 64
// bytes.
func digestIndexPath(srcFS string, info os.FileInfo) string {
key := pathDigest(srcFS, info)
return filepath.Join(thumbCacheDir, "digests", key[:2], key)
}
func rememberDigest(srcFS string, info os.FileInfo, h string) {
digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock()
return h, data, nil
}
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
+43 -16
View File
@@ -57,6 +57,14 @@ func resizeBox(src image.Image, width int) image.Image {
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
// JPEGs decode to *image.YCbCr. Reading its planes directly avoids an
// interface call and a colour conversion per source pixel, which makes the
// resize ~4x faster; everything else takes the generic At() path.
avg := boxAvg
if ycc, ok := src.(*image.YCbCr); ok {
avg = func(_ image.Image, r image.Rectangle) color.RGBA { return boxAvgYCbCr(ycc, r) }
}
for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH
@@ -69,23 +77,42 @@ func resizeBox(src image.Image, width int) image.Image {
if sx1 == sx0 {
sx1 = sx0 + 1
}
var r, g, bl, n uint64
for sy := sy0; sy < sy1; sy++ {
for sx := sx0; sx < sx1; sx++ {
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min)
dst.SetRGBA(x, y, avg(src, box))
}
}
return dst
}
// boxAvg returns the mean colour of the pixels of src inside r.
func boxAvg(src image.Image, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
sr, sg, sb, _ := src.At(x, y).RGBA()
red += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly.
// Each pixel is still converted to RGB before averaging so out-of-gamut
// samples clamp exactly as they do through At().
func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
ci := src.COffset(x, y)
sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci])
red += uint64(sr)
g += uint64(sg)
bl += uint64(sb)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
)
// testPhoto returns a JPEG-decoded (so *image.YCbCr, 4:2:0) image with an odd
// size and a non-zero origin to exercise the plane offset arithmetic.
func testPhoto(t testing.TB, w, h int) *image.YCbCr {
t.Helper()
src := image.NewRGBA(image.Rect(0, 0, w, h))
for i := range src.Pix {
src.Pix[i] = uint8(i*7 + i/w)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
img, err := jpeg.Decode(&buf)
if err != nil {
t.Fatal(err)
}
return img.(*image.YCbCr).SubImage(image.Rect(3, 5, w, h)).(*image.YCbCr)
}
func TestResizeBoxYCbCrMatchesGeneric(t *testing.T) {
src := testPhoto(t, 1001, 667)
fast := resizeBox(src, 120).(*image.RGBA)
// Hide the concrete type so resizeBox takes the generic At() path.
slow := resizeBox(struct{ image.Image }{src}, 120).(*image.RGBA)
if fast.Bounds() != slow.Bounds() {
t.Fatalf("bounds %v != %v", fast.Bounds(), slow.Bounds())
}
for i := range fast.Pix {
if fast.Pix[i] != slow.Pix[i] {
t.Fatalf("pixel byte %d: fast %d, generic %d", i, fast.Pix[i], slow.Pix[i])
}
}
}
func BenchmarkResizeBox(b *testing.B) {
src := testPhoto(b, 4000, 3000)
b.Run("ycbcr", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(src, 300)
}
})
b.Run("generic", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(struct{ image.Image }{src}, 300)
}
})
}
// After a restart (empty memory cache) the digest comes from the on-disk index
// without reading the source file.
func TestSourceDigestPersists(t *testing.T) {
old := thumbCacheDir
thumbCacheDir = t.TempDir()
t.Cleanup(func() { thumbCacheDir = old })
src := filepath.Join(t.TempDir(), "a.jpg")
if err := os.WriteFile(src, []byte("photo"), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(src)
h1, data, err := sourceDigest(src, info)
if err != nil || data == nil {
t.Fatalf("first call: %v, data=%v", err, data)
}
digestCacheMu.Lock()
delete(digestCache, src)
digestCacheMu.Unlock()
// Removing the source proves the second call does not read it again.
if err := os.Remove(src); err != nil {
t.Fatal(err)
}
h2, data, err := sourceDigest(src, info)
if err != nil || data != nil || h2 != h1 {
t.Fatalf("after restart: h=%s data=%v err=%v, want %s from index", h2, data, err, h1)
}
}
+36 -1
View File
@@ -12,6 +12,10 @@ import (
type treeEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
// HasChildren reports whether a folder has any visible entries (under the
// same foldersOnly filter), so the client can hide the expand chevron on
// empty folders without a round-trip. Always false for files.
HasChildren bool `json:"hasChildren"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
@@ -81,7 +85,11 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
continue
}
if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
folders = append(folders, treeEntry{
Name: name,
Kind: "folder",
HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly),
})
} else {
if foldersOnly || name == "index.md" {
continue
@@ -94,6 +102,33 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
return append(folders, files...), nil
}
// folderHasChildren reports whether fsPath contains at least one entry that
// listTreeEntries would list. It reads the directory in small batches and
// stops at the first match, so large folders stay cheap. Unreadable folders
// report false.
func folderHasChildren(fsPath string, foldersOnly bool) bool {
f, err := os.Open(fsPath)
if err != nil {
return false
}
defer f.Close()
for {
batch, err := f.ReadDir(32)
for _, e := range batch {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if e.IsDir() || (!foldersOnly && name != "index.md") {
return true
}
}
if err != nil {
return false
}
}
}
// treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string {
var segs []string