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
+88 -79
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
Designed for a chronological photo diary. The whole year lives in a single
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
```
FolderName/
Diary/
.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
2026/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
2026-05-11 Beach.jpg ← photos go under their date's section
```
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.
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.
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.
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
#### Persistent date links
## Fitness
Each diary root exposes three stable paths intended for browser bookmarks.
They resolve against the year page rather than separate per-day URLs:
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.
| 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/` |
## Calendars
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
```
Calendars/
.page-settings ← type = calendars, roles, sync credentials
Personal.ics ← one file per calendar
Gewicht.ics
…
```
## Quick-Add Bookmarklet
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.
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
```
type = calendars
# Roles: file names without .ics
diary = Personal, Feiertage
weight = Gewicht
maintenance = Maintainance
# 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
```
| 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** |
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.
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
## 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);
}
+69 -5
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);
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';
});
+10 -3
View File
@@ -2,19 +2,23 @@
<section class="fitness-chart panel">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<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>
+9 -1
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,7 +64,8 @@ document.addEventListener('keydown', function (e) {
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
['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');
@@ -67,3 +74,4 @@ document.addEventListener('DOMContentLoaded', 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) {
+32 -2
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")
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>`)
}
+165 -165
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 {
@@ -292,6 +219,7 @@ type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Absolute bool
Empty bool
ViewW, ViewH 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))
}
}
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
}
prev = label
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
}
}
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=
+86 -48
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 },
"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")
}
}
+40 -3
View File
@@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -50,6 +51,11 @@ var (
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) {
+39 -12
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)
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++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
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 dst
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