Compare commits

...
19 Commits
Author SHA1 Message Date
luxickandClaude Opus 5.5 aa90a04f91 Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead
of loading a new document: the server answers boosted requests and
history restores with a partial (title, page actions out-of-band, shell
content). The header stays, browsers without cross-document view
transitions (Firefox) no longer flash between pages, and the swap runs
as a same-document view transition. Files, the editor, internal
endpoints and POST forms still navigate normally.

Back/Forward refetch pages (no snapshots that could predate a move or
delete); the tree rail keeps its scroll position and the content column
returns to where it was. Mutation redirects swap their result in the
same way. Page scripts that assumed a fresh document now also run after
a swap (toc, contents, companion, scroll-sync, search actions) or
register once (fitness).

The e2e suite now also runs in Firefox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:29:08 +02:00
luxickandClaude Opus 5.5 fa90b3baee Revert "Declare a dark color scheme"
It did not remove the navigation flicker in Firefox; boosted navigation
will address it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:15:43 +02:00
luxickandClaude Opus 5.5 20f0ae5111 Declare a dark color scheme
Firefox has no cross-document view transitions and paints its default
white canvas between pages; color-scheme makes that frame dark. Native
controls (checkboxes, scrollbars) now use their dark variants too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:14:00 +02:00
luxickandClaude Opus 5.5 14fadf096a Make e2e tests independent and run them in parallel
Each test builds its pages in its own sandbox folder (search tests use a
random word), so tests no longer rely on earlier tests' leftovers and
run alone, in any order, or in parallel. The seed is just the home page.

Parallel load hid the htmx settle race from the timing-based tests, so
a dedicated test now submits a dialog in the task right after it is
inserted, which fails deterministically without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:01:16 +02:00
luxickandClaude Opus 5.5 a55a1c8ed4 Render search suggestions on the server
The typeahead requests /_search via htmx (debounced, newer requests
replace in-flight ones) and gets rendered rows with the match
highlighting done in Go. Rows are links, so picking in the header is
plain navigation; the editor's link dialog reads data-name instead. The
editor's [[ autocomplete keeps the JSON response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:00:08 +02:00
luxickandClaude Opus 5.5 4365fa2567 Render the folder tree on the server
The sidebar tree is part of the page (chain to the current page
expanded, active row marked), so it no longer paints late and the
sessionStorage snapshot is gone; only the rail's scroll position is
kept. Folders are native <details> that lazy-load their children as
HTML via htmx. The tree picker hosts the same server-rendered rows with
radio inputs, so ?tree returns HTML and the JSON listing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:51:20 +02:00
luxickandClaude Opus 5.5 353df29ee6 Toggle tasks and edit view settings via htmx
Task checkboxes post themselves (hx-post="?toggle=N") and flip back when
the save fails, replacing tasks.js. View settings are a server-rendered
form dialog with real radios and a select, replacing view-settings.js;
with that, postReplace has no callers left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:41:59 +02:00
luxickandClaude Opus 5.5 3a3fb3b51f Serve delete, move and merge dialogs as htmx fragments
The server renders the dialogs (?dialog=delete|move) and answers a move
that needs confirmation with the merge dialog itself, so the client no
longer builds action URLs or interprets X-Merge-Available. Successful
mutations send HX-Redirect, which hx.js turns into a history replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:37:46 +02:00
luxick 465aed47b3 Add tests 2026-09-30 16:16:00 +02:00
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
74 changed files with 4579 additions and 1645 deletions
+13 -4
View File
@@ -4,20 +4,24 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64 COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum 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_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
.PHONY: deploy companion companion-windows companion-linux companion-release editor .PHONY: deploy companion companion-windows companion-linux companion-release editor e2e
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist # Cross-compiled companion artifacts the wiki binary embeds. Both must exist
# before `go build .` so embed.FS picks them up. # before `go build .` so embed.FS picks them up.
companion-release: $(COMPANION_WIN) $(COMPANION_LIN) companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
$(COMPANION_WIN): $(COMPANION_SRCS) $(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) $(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-windows: $(COMPANION_WIN)
companion-linux: $(COMPANION_LIN) companion-linux: $(COMPANION_LIN)
@@ -37,8 +41,13 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS)
cd editor-build && npm ci && npm run build cd editor-build && npm ci && npm run build
deploy: companion-release 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' 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 scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
rm datascape-arm rm datascape-arm
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
e2e:
cd e2e && npm install --no-audit --no-fund && npx playwright test
+84 -75
View File
@@ -4,117 +4,126 @@ Minimal self-hosted personal wiki. Folders are pages.
## Features ## 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. The wiki binary embeds the companion binaries, so build those first:
- **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
```bash ```bash
# local make companion-release # once, and after companion changes
go build -o datascape . go build -o datascape . # local
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
# QNAP NAS (linux/arm) ./datascape -dir ./wiki -addr :8080 -user me -pass secret
GOOS=linux GOARCH=arm go build -o datascape .
``` ```
## Usage | Flag | Default | |
|------|---------|-|
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
| Flag | Default | Description |
|------|---------|-------------|
| `-addr` | `:8080` | Listen address | | `-addr` | `:8080` | Listen address |
| `-dir` | `./wiki` | Wiki root directory | | `-dir` | `./wiki` | Wiki root |
| `-cache` | `./cache` | Thumbnail cache directory | | `-cache` | `./cache` | Thumbnail cache |
| `-user` | _(none)_ | Basic auth username — omit to disable auth | | `-user`, `-pass` | | Basic auth (omit to disable) |
| `-pass` | _(none)_ | Basic auth password | | `-reindex-interval` | `30m` | Search index rebuild (`0` = off) |
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) | | `-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, Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys.
persisting three keys to `.page-settings`:
| Key | Values (default first) | | Key | Values (default first) |
|------|------------------------| |-----|------------------------|
| `view` | `list`, `thumbnail` | | `view` | `list`, `thumbnail` |
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) | | `sort` | `name`, `modified`, `size` |
| `order` | `asc`, `desc` | | `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 All use Alt+Shift.
same file also holds the [View Settings](#view-settings) keys; only the `type`
key selects a special renderer: | Where | Keys |
|-------|------|
| Anywhere | `E` edit, `N` new page, `M` move, `F` search, `H` maintenance |
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
## Diary
``` ```
type = diary Diary/
.page-settings ← type = diary
2026/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
2026-05-11 Beach.jpg ← photos go under their date's section
``` ```
### Diary The year page shows each day's section with its photos. Months and days that are missing (up to today) appear as placeholders with an `[edit]` button. A calendar in the sidebar jumps to any date.
Designed for a chronological photo diary. The whole year lives in a single Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
## Fitness
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
## Calendars
``` ```
FolderName/ Calendars/
.page-settings ← type = diary .page-settings ← type = calendars, roles, sync credentials
YYYY/ Personal.ics ← one file per calendar
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections Gewicht.ics
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to …
``` ```
The year page (`YYYY/`) renders every section in the file with photos 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.
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.
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 type = calendars
jumps between years. Day cells link to the matching anchor on the year page
regardless of whether the date has a real section yet.
#### Persistent date links # Roles: file names without .ics
diary = Personal, Feiertage
weight = Gewicht
maintenance = Maintainance
Each diary root exposes three stable paths intended for browser bookmarks. # Sync (Nextcloud)
They resolve against the year page rather than separate per-day URLs: caldav-url = https://cloud.example/remote.php/dav/calendars/me/
caldav-user = me
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
sync-interval = 15m
```
| Path | Redirects to | | Key | |
|------|-------------| |-----|-|
| `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) | | `diary` | Events shown under each diary day: all-day first, then by start time |
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` | | `weight` | Titles like `95,6kg`, shown under the diary day's events |
| `<diary>/this-year/` | `<diary>/YYYY/` | | `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
| `caldav-url` | Calendar home. Empty = no sync |
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page. Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder.
## Quick-Add Bookmarklet The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): ## Quick-add bookmarklet
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page:
```javascript ```javascript
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');})(); 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 ```markdown
- [Example Page](https://example.com) - [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>
+11 -4
View File
@@ -200,13 +200,20 @@
// leaves the status icon and reveal buttons invisible on mobile. // leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return; if (isMobileDevice()) return;
probeStatus().then(function () { probeStatus().then(function () {
updateFooterIcon(); wireFileLinks(); // delegated to document: once
wireFileLinks(); applyToPage();
wireRevealButton(); // Boosted navigation swaps in a new page without reloading this
wireFileRevealButtons(); // script: re-apply to its footer icon and reveal buttons.
document.addEventListener('page:swapped', applyToPage);
}); });
} }
function applyToPage() {
updateFooterIcon();
wireRevealButton();
wireFileRevealButtons();
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
+1 -1
View File
@@ -24,4 +24,4 @@
</table> </table>
{{end}} {{end}}
</div> </div>
<script src="/_/diary/calendar.js"></script> <script src="{{asset "diary/calendar.js"}}"></script>
+4
View File
@@ -10,4 +10,8 @@
{{end}} {{end}}
</div> </div>
{{end}} {{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}} {{end}}
+6 -6
View File
@@ -78,10 +78,10 @@
<div id="editor" class="editor-cm"></div> <div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea> <textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form> </form>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script> <script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="/_/editor/tables.js"></script> <script src="{{asset "editor/tables.js"}}"></script>
<script src="/_/editor/dates.js"></script> <script src="{{asset "editor/dates.js"}}"></script>
<script src="/_/editor/movie.js"></script> <script src="{{asset "editor/movie.js"}}"></script>
<script src="/_/editor/wikicomplete.js"></script> <script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="/_/editor/main.js"></script> <script src="{{asset "editor/main.js"}}"></script>
{{end}} {{end}}
+23 -10
View File
@@ -6,22 +6,34 @@ window.EditorTables = (function () {
return s; 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) { function padLeft(s, w) {
while (s.length < w) s = ' ' + s; return repeat(' ', w - displayWidth(s)) + s;
return s;
} }
function padRight(s, w) { function padRight(s, w) {
while (s.length < w) s += ' '; return s + repeat(' ', w - displayWidth(s));
return s;
} }
function padCenter(s, w) { function padCenter(s, w) {
while (s.length < w) { var total = w - displayWidth(s);
s = s + ' '; var left = Math.floor(total / 2);
if (s.length < w) s = ' ' + s; return repeat(' ', left) + s + repeat(' ', total - left);
}
return s;
} }
function parseTableRow(line) { function parseTableRow(line) {
@@ -101,7 +113,8 @@ window.EditorTables = (function () {
var max = 3; var max = 3;
for (var r = 0; r < rows.length; r++) { for (var r = 0; r < rows.length; r++) {
if (r === sepIndex) continue; 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); widths.push(max);
} }
+76 -6
View File
@@ -1,10 +1,80 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that // Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
// chart's query parameter updated. Plain GET navigation — each range is a // reloads the page with that chart's query parameter updated. Plain GET
// distinct, bookmarkable view, so no postReplace/history handling is needed. // navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
// Boosted navigation re-runs this script on every visit; the listeners
// are delegated to document, so register them once.
if (!window.fitnessWired) {
window.fitnessWired = true;
document.addEventListener('change', function (e) { document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href); var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value); var sel = e.target.closest('[data-fitness-range]');
var abs = e.target.closest('[data-fitness-absolute]');
if (sel) {
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
} else if (abs) {
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
} else {
return;
}
window.location.href = url.toString(); window.location.href = url.toString();
}); });
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
// are sorted by x, so a binary search finds it; the tooltip sits above the
// point and is clamped to the chart panel.
function fitnessNearest(dots, x) {
var lo = 0, hi = dots.length - 1;
while (lo < hi) {
var mid = (lo + hi) >> 1;
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
}
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
return dots[lo];
}
function fitnessHide(svg) {
var chart = svg.closest('.fitness-chart');
svg.classList.remove('is-hover');
chart.querySelector('.fitness-tip').hidden = true;
}
document.addEventListener('pointermove', function (e) {
var svg = e.target.closest && e.target.closest('.fitness-svg');
document.querySelectorAll('.fitness-svg').forEach(function (other) {
if (other !== svg) fitnessHide(other);
});
if (!svg) return;
var dots = svg.querySelectorAll('.chart-dot');
if (!dots.length) return;
var ctm = svg.getScreenCTM();
if (!ctm) return;
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
var dot = fitnessNearest(dots, pt.x);
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
var cursor = svg.querySelector('.chart-cursor');
cursor.setAttribute('x1', cx);
cursor.setAttribute('x2', cx);
var focus = svg.querySelector('.chart-focus');
focus.setAttribute('cx', cx);
focus.setAttribute('cy', cy);
svg.classList.add('is-hover');
var chart = svg.closest('.fitness-chart');
var tip = chart.querySelector('.fitness-tip');
tip.firstElementChild.textContent = dot.dataset.date;
tip.lastElementChild.textContent = dot.dataset.value;
tip.hidden = false;
var box = chart.getBoundingClientRect();
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
var left = at.x - box.left - tip.offsetWidth / 2;
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
var top = at.y - box.top - tip.offsetHeight - 10;
if (top < 0) top = at.y - box.top + 10;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
});
}
+13 -6
View File
@@ -2,19 +2,23 @@
<section class="fitness-chart panel"> <section class="fitness-chart panel">
<div class="fitness-chart-header row space-between"> <div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span> <span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range"> <div class="row fitness-controls">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}} <label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
</select> <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> </div>
{{if .Empty}} {{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p> <p class="fitness-empty is-empty">No data in this range.</p>
{{else}} {{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}} {{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/> <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> <text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}} {{end}}
{{range .XTicks}} {{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> <text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}} {{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/> <line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -23,13 +27,16 @@
<polyline class="chart-line" points="{{.}}"/> <polyline class="chart-line" points="{{.}}"/>
{{end}} {{end}}
{{range .Dots}} {{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}} {{end}}
{{if .Goal}} {{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/> <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> <text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}} {{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg> </svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
@@ -40,4 +47,4 @@
{{range .Charts}}{{template "fitnessChart" .}}{{end}} {{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}} {{end}}
</div> </div>
<script src="/_/fitness/fitness.js"></script> <script src="{{asset "fitness/fitness.js"}}"></script>
+15 -7
View File
@@ -16,6 +16,12 @@
e.preventDefault(); e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage(); if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break; break;
case 'H':
var maint = document.querySelector('[data-action="maint-menu"]');
if (!maint) return;
e.preventDefault();
maint.click();
break;
case 'F': case 'F':
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (!input) return; if (!input) return;
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
}); });
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]'); ['actions-menu', 'maint-menu'].forEach(function (action) {
if (!trigger) return; var trigger = document.querySelector('[data-action="' + action + '"]');
wireDropdown(trigger); if (!trigger) return;
var menu = trigger.parentElement.querySelector('.dropdown-menu'); wireDropdown(trigger);
if (!menu) return; var menu = trigger.parentElement.querySelector('.dropdown-menu');
trigger.addEventListener('click', function () { if (!menu) return;
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false); trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
}); });
}); });
+140
View File
@@ -0,0 +1,140 @@
// Site-wide htmx policy. Loaded right after htmx itself.
(function () {
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
// settle step, by default 20ms after the swap: a click or Enter in that
// window would submit the form natively. No settle transitions are used,
// so settle in the same tick.
htmx.config.defaultSettleDelay = 0;
function boosting() {
return document.body.getAttribute('hx-boost') === 'true';
}
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
// target into the current history entry rather than pushing a new one, so
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
// navigation; elsewhere replaceState + reload (not location.replace: a
// target differing only by #fragment would otherwise not re-fetch).
htmx.location = {
set href(target) {
if (boosting()) {
if (window.closeModal) closeModal();
htmx.ajax('GET', target, {
target: '.shell',
swap: 'innerHTML transition:true',
headers: { 'HX-Boosted': 'true' },
replace: target
});
return;
}
window.history.replaceState(null, '', target);
window.location.reload();
},
reload: function () { window.location.reload(); }
};
// Failed requests answer with a plain-text reason; show it. A failed
// boosted navigation falls back to loading the page normally instead.
function fallBack(e) {
if (!e.detail.boosted) return false;
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
return true;
}
document.addEventListener('htmx:responseError', function (e) {
if (fallBack(e)) return;
var xhr = e.detail.xhr;
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
});
document.addEventListener('htmx:sendError', function (e) {
if (fallBack(e)) return;
alert('Network error');
});
// --- Boosted navigation ---------------------------------------------
// Outside the editor, links and GET forms to wiki pages load the next page
// into .shell instead of replacing the document: the server answers with
// the "partial" (title, page actions out-of-band, shell content). The
// header stays put, nothing flashes in browsers without cross-document
// view transitions, and the swap runs as a same-document view transition.
// Back/Forward refetch the partial (hx-history="false" on .shell: no
// snapshots that could show a page from before a move or delete).
htmx.config.scrollIntoViewOnBoost = false;
// Only wiki pages are boosted: same origin, a slash-terminated path, no
// query except a search (?q=). Files, the editor (?edit), internal
// endpoints (/_…) and POST forms navigate normally.
function isPageURL(url) {
if (url.origin !== location.origin) return false;
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
var ok = true;
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
return ok;
}
// htmx decides per element whether it is boosted, right after this event.
document.addEventListener('htmx:beforeProcessNode', function (e) {
var elt = e.target;
if (elt.hasAttribute('hx-boost')) return;
var page = false;
if (elt.tagName === 'A') {
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
} else if (elt.tagName === 'FORM') {
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
} else {
return;
}
if (!page) elt.setAttribute('hx-boost', 'false');
});
function isShell(elt) {
return elt && elt.classList && elt.classList.contains('shell');
}
// Scroll positions: the tree rail keeps its position across navigation;
// the content column (.center) starts at the top, or where it was when
// coming back via Back/Forward.
var treeTop = 0;
var restoring = false;
var centerTops = {};
function pageKey() {
return location.pathname + location.search;
}
function rememberTree() {
var tree = document.querySelector('.tree-scroll');
treeTop = tree ? tree.scrollTop : 0;
}
document.addEventListener('scroll', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
}, { capture: true, passive: true });
document.addEventListener('htmx:beforeSwap', function (e) {
if (e.detail.boosted) {
e.detail.target = document.querySelector('.shell');
e.detail.swapOverride = 'innerHTML transition:true';
}
if (isShell(e.detail.target)) rememberTree();
});
document.addEventListener('htmx:historyCacheMissLoad', function () {
restoring = true;
rememberTree();
});
// After a new page is in .shell: restore scroll positions, then tell page
// scripts (companion.js, …) to apply themselves to the new content.
document.addEventListener('htmx:afterSettle', function (e) {
if (!isShell(e.target)) return;
var tree = document.querySelector('.tree-scroll');
if (tree) {
tree.scrollTop = treeTop;
var active = tree.querySelector('.tree-row.is-active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
if (restoring) {
restoring = false;
var center = document.querySelector('.center');
if (center) center.scrollTop = centerTops[pageKey()] || 0;
}
document.dispatchEvent(new CustomEvent('page:swapped'));
});
})();
+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

+74 -34
View File
@@ -4,22 +4,27 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title> <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/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.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" /> <link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="/_/modal.js"></script> <script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="/_/global-shortcuts.js"></script> <script src="{{asset "hx.js"}}" defer></script>
<script src="/_/history-nav.js"></script> <script src="{{asset "modal.js"}}"></script>
<script src="/_/scroll-sync.js" defer></script> <script src="{{asset "global-shortcuts.js"}}"></script>
<script src="/_/search-suggest.js" defer></script> <script src="{{asset "history-nav.js"}}"></script>
<script src="/_/tree-picker.js"></script> <script src="{{asset "scroll-sync.js"}}" defer></script>
<script src="/_/companion.js" defer></script> <script src="{{asset "search-suggest.js"}}" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script> <script src="{{asset "tree-picker.js"}}"></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}} <script src="{{asset "companion.js"}}" defer></script>
{{block "headScripts" .}}{{end}} {{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="{{asset "page/actions.js"}}"></script>
<script src="{{asset "search/actions.js"}}"></script>{{end}}
</head> </head>
<body> {{/* Outside the editor, links to pages are boosted: htmx swaps .shell
(plus title and page actions, see "partial") instead of loading a new
document. hx.js decides which links count as pages. */}}
<body{{if not .EditMode}} hx-boost="true"{{end}}>
<header> <header>
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
@@ -31,36 +36,71 @@
{{end}} {{end}}
<div class="header-actions row"> <div class="header-actions row">
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}} {{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
{{maintenanceWidget}}
<span class="dropdown actions"> <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> <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"> <div class="dropdown-menu align-right actions-menu">
<div class="actions-special"></div> {{template "page-actions" .}}
{{template "headerActions" . }}
</div> </div>
</span> </span>
{{end}} {{end}}
</div> </div>
</header> </header>
<div class="shell"> <div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
{{if not .EditMode}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}} {{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{/* The page-specific header actions; the partial swaps them out-of-band
(the attribute is inert in a full page). */}}
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
<div class="actions-special">
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
</div>
{{template "headerActions" .}}
</div>{{end}}
{{define "shell"}}
{{if not .EditMode}}<aside class="tree-sidebar col">
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
<script>
// Full page loads: keep the rail's scroll position (before first
// paint, so it doesn't jump), then reveal the active row. Boosted
// navigation keeps it itself (hx.js); this script re-runs then and
// bails out.
(function () {
if (document.readyState !== "loading") return;
var el = document.currentScript.previousElementSibling;
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
var active = el.querySelector(".tree-row.is-active");
if (active) active.scrollIntoView({ block: "nearest" });
addEventListener("pagehide", function () {
var cur = document.querySelector(".tree-scroll");
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
});
})();
</script>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
{{end}}
{{/* Response to boosted navigation and history restores (hx.js swaps it into
.shell): the title, the page actions out-of-band, and the shell's content. */}}
{{define "partial"}}<title>{{.Title}}</title>
{{template "page-actions" .}}
{{template "shell" .}}{{end}}
+144 -136
View File
@@ -1,120 +1,111 @@
// One modal at a time, from either of two sources sharing the same chrome
// (backdrop click, Escape, drag by the header, focus restore):
// - openModal(opts) builds a modal from JS;
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
// #modal-host. Their forms post themselves; a [data-modal-close] element
// closes them.
(function () { (function () {
var backdrop = null; var current = null; // { backdrop, prevFocus, onKeydown }
var modal = null; var drag = null; // { modal, startX, startY, originX, originY }
var titleEl = null;
var bodyEl = null;
var cancelBtn = null;
var confirmBtn = null;
var footerEl = null;
var prevFocus = null;
var currentOpts = null;
var onKeydown = null;
function build() { // show mounts backdrop (whose child is the .modal) into parent and makes
backdrop = document.createElement('div'); // it the current modal. onKeydown handles keys beyond Escape.
function show(backdrop, parent, prevFocus, onKeydown) {
var modal = backdrop.querySelector('.modal');
var header = modal.querySelector('.panel-header');
function onDown(e) { startDrag(e, modal); }
header.addEventListener('mousedown', onDown);
header.addEventListener('touchstart', onDown, { passive: false });
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
backdrop.addEventListener('click', function (e) {
if (e.target.closest('[data-modal-close]')) close();
});
var keydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (onKeydown) onKeydown(e);
};
document.addEventListener('keydown', keydown);
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
}
// Dragging switches the modal to fixed positioning so flexbox alignment
// doesn't fight us.
function startDrag(e, modal) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
e.preventDefault();
}
function onMove(e) {
if (!drag) return;
var pt = e.touches ? e.touches[0] : e;
var m = drag.modal;
var maxX = window.innerWidth - m.offsetWidth;
var maxY = window.innerHeight - m.offsetHeight;
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
}
function onUp() { drag = null; }
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
// detach tears down the current modal and returns the element that had
// focus before it opened. keepDOM leaves the backdrop in place for a swap
// about to replace it: htmx must still find the element that issued the
// request in the document.
function detach(keepDOM) {
if (!current) return null;
var c = current;
current = null;
drag = null;
document.removeEventListener('keydown', c.onKeydown);
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
return c.prevFocus;
}
function close() {
var toRestore = detach();
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
function open(opts) {
var prevFocus = current ? detach() : document.activeElement;
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop'; backdrop.className = 'modal-backdrop';
var modal = document.createElement('div');
modal = document.createElement('div');
modal.className = 'modal panel panel-floating'; modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog'); modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div'); var header = document.createElement('div');
header.className = 'panel-header'; header.className = 'panel-header';
titleEl = document.createElement('span'); var titleEl = document.createElement('span');
titleEl.textContent = opts.title || '';
header.appendChild(titleEl); header.appendChild(titleEl);
bodyEl = document.createElement('div'); var bodyEl = document.createElement('div');
bodyEl.className = 'panel-body'; bodyEl.className = 'panel-body';
footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
wireDrag(header);
cancelBtn.addEventListener('click', close);
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
currentOpts.confirm.onConfirm();
}
});
}
// wireDrag makes the modal draggable by `handle`. Dragging switches the
// modal to fixed positioning so flexbox alignment doesn't fight us.
function wireDrag(handle) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
dragging = true;
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var pt = e.touches ? e.touches[0] : e;
var dx = pt.clientX - startX;
var dy = pt.clientY - startY;
var w = modal.offsetWidth;
var h = modal.offsetHeight;
var maxX = window.innerWidth - w;
var maxY = window.innerHeight - h;
var x = Math.max(0, Math.min(maxX, originX + dx));
var y = Math.max(0, Math.min(maxY, originY + dy));
modal.style.left = x + 'px';
modal.style.top = y + 'px';
}
function onUp() { dragging = false; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
}
function open(opts) {
if (backdrop && backdrop.parentNode) close();
if (!backdrop) build();
currentOpts = opts;
prevFocus = document.activeElement;
modal.classList.remove('is-dragged');
modal.style.position = '';
modal.style.left = '';
modal.style.top = '';
titleEl.textContent = opts.title || '';
bodyEl.textContent = '';
if (opts.body instanceof Node) { if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body); bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') { } else if (typeof opts.body === 'string') {
@@ -124,14 +115,24 @@
var confirmOpts = opts.confirm || {}; var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {}; var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK'; confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : ''); confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
confirmBtn.disabled = !!confirmOpts.initiallyDisabled; confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = cancelOpts.label || 'CANCEL'; cancelBtn.textContent = cancelOpts.label || 'CANCEL';
cancelBtn.className = 'btn'; cancelBtn.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
footerEl.textContent = ''; var footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
if (opts.swapButtons) { if (opts.swapButtons) {
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn); footerEl.appendChild(cancelBtn);
@@ -140,7 +141,20 @@
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
} }
document.body.appendChild(backdrop); modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
var enterConfirms = confirmOpts.enterConfirms !== false;
show(backdrop, document.body, prevFocus, function (e) {
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
});
setTimeout(function () { setTimeout(function () {
if (cancelOpts.autofocus) { if (cancelOpts.autofocus) {
@@ -156,22 +170,6 @@
} }
}, 0); }, 0);
var enterConfirms = confirmOpts.enterConfirms !== false;
onKeydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
};
document.addEventListener('keydown', onKeydown);
return { return {
close: close, close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; }, setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
@@ -179,20 +177,30 @@
}; };
} }
function close() { // Server-rendered dialogs. A swap into #modal-host replaces whatever modal
if (!backdrop || !backdrop.parentNode) return; // is open (e.g. the merge step replacing the move dialog); focus returns
if (onKeydown) { // to where it was before the first one opened.
document.removeEventListener('keydown', onKeydown); var hostPrevFocus = null;
onKeydown = null; // History restores swap without event details; they never target the host.
} function isHost(e) {
backdrop.parentNode.removeChild(backdrop); return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
} }
document.addEventListener('htmx:beforeSwap', function (e) {
if (!isHost(e)) return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
if (!isHost(e)) return;
var host = e.detail.target;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
if (f) {
f.focus();
if (f.select) f.select();
}
});
window.openModal = open; window.openModal = open;
window.closeModal = close; window.closeModal = close;
+7 -116
View File
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
} }
// postReplace POSTs to action with the optional form body, then loads target
// into the current history entry — so the action and its result occupy one
// entry instead of two, and back-navigation skips past the stale pre-mutation
// snapshot in bfcache. body may be null for empty POSTs.
//
// We can't just call window.location.replace(target): when target differs from
// the current URL only by fragment, the browser updates the URL bar without
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' };
if (body) {
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = body;
}
fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function promptPageName(title, initial, confirmLabel, onName) { function promptPageName(title, initial, confirmLabel, onName) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
@@ -75,45 +41,10 @@ function newPage() {
}); });
} }
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() { function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return; if (!current) return;
var segs = current.split('/').filter(Boolean); var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
var currentName = segs[segs.length - 1] || '';
var parent = '/' + segs.slice(0, -1).join('/');
if (parent === '/') parent = '/';
openTreePicker({ openTreePicker({
title: 'Move — new parent?', title: 'Move — new parent?',
@@ -123,53 +54,13 @@ function movePage() {
hideFiles: true, hideFiles: true,
confirmLabel: 'NEXT', confirmLabel: 'NEXT',
onSelect: function (newParent) { onSelect: function (newParent) {
var input = document.createElement('input'); // The server renders the name step and handles what follows
input.type = 'text'; // (merge confirmation, errors, redirect).
input.className = 'input'; closeModal();
input.placeholder = 'Page name'; htmx.ajax('GET', window.location.pathname + '?dialog=move', {
input.value = currentName; target: '#modal-host',
values: { parent: newParent }
openModal({
title: 'Move — new name?',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
}
}
}); });
} }
}); });
} }
function deletePage() {
var decodedPath = decodeURIComponent(window.location.pathname);
openModal({
title: 'Delete page',
body: 'Delete ' + decodedPath + ' and everything inside it?',
confirm: {
label: 'DELETE',
danger: true,
enterConfirms: false,
onConfirm: function () {
var p = window.location.pathname.replace(/\/+$/, '');
var idx = p.lastIndexOf('/');
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
closeModal();
postReplace(window.location.pathname + '?delete=1', null, parent);
}
},
cancel: { autofocus: true },
swapButtons: true
});
}
+20 -14
View File
@@ -1,15 +1,21 @@
document.addEventListener("DOMContentLoaded", function () { // Runs on DOMContentLoaded for a full page load, and right away when this
var aside = document.querySelector("aside.sidebar"); // script arrives with a boosted navigation (the document is loaded then).
var specials = document.querySelector(".actions-special"); (function () {
if (!aside || !aside.children.length || !specials) return; function run() {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
var item = document.createElement("button"); var item = document.createElement("button");
item.type = "button"; item.type = "button";
item.className = "btn btn-block"; item.className = "btn btn-block";
item.title = "Contents"; item.title = "Contents";
item.textContent = "CONTENTS"; item.textContent = "CONTENTS";
item.addEventListener("click", function () { item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside); if (typeof openOverlay === "function") openOverlay(aside);
}); });
specials.appendChild(item); specials.appendChild(item);
}); }
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+80
View File
@@ -0,0 +1,80 @@
{{/* Server-rendered dialogs, fetched with ?dialog=<name> and swapped into
#modal-host by htmx; modal.js supplies the chrome (backdrop, Escape, drag,
focus). Each dialog is a form that posts the action it offers, so the client
never builds action URLs itself. Responses to those posts either redirect
(HX-Redirect), replace the dialog with the next step (e.g. the merge
confirmation), or fail with a plain-text error. */}}
{{define "delete"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?delete=1" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Delete page</span></div>
<div class="panel-body">Delete {{.Path}} and everything inside it?</div>
<div class="panel-footer">
<button class="btn danger" type="submit">DELETE</button>
<button class="btn" type="button" data-modal-close autofocus>CANCEL</button>
</div>
</form>
</div>{{end}}
{{define "move"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
<div class="panel-body">
<input type="hidden" name="parent" value="{{.Parent}}" />
<input class="input" type="text" name="name" value="{{.Name}}" placeholder="Page name" required autofocus />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit">MOVE</button>
</div>
</form>
</div>{{end}}
{{define "merge"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Merge folders?</span></div>
<div class="panel-body">
The destination folder already exists but has no page of its own. Merge this page and its contents into it?
<input type="hidden" name="move" value="{{.Dest}}" />
<input type="hidden" name="merge" value="1" />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>MERGE</button>
</div>
</form>
</div>{{end}}
{{define "view"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">View settings</span></div>
<div class="panel-body col">
<fieldset class="col gap-1">
<legend class="caption">View style</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
</div>
</fieldset>
<label class="col gap-1">
<span class="caption">Sort by</span>
<select class="input" name="sort">
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
</select>
</label>
<fieldset class="col gap-1">
<legend class="caption">Order</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
</div>
</fieldset>
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>SAVE</button>
</div>
</form>
</div>{{end}}
+13 -18
View File
@@ -1,13 +1,11 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}} {{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</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">REVEAL</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}} {{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</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">DELETE</button> <button class="btn btn-block danger" hx-get="?dialog=delete" hx-target="#modal-host" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
{{end}} {{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}} {{end}}{{end}}
{{define "content"}} {{define "content"}}
@@ -17,8 +15,11 @@
{{if .SpecialContent}} {{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div> <div class="content">{{.SpecialContent}}</div>
{{end}} {{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}} {{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> <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" hx-get="?dialog=view" hx-target="#modal-host" title="View &amp; sorting">view</button>{{end}}</h2>
{{if eq .View "thumbnail"}} {{if eq .View "thumbnail"}}
<div class="thumb-grid"> <div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
@@ -41,21 +42,15 @@
</tbody> </tbody>
</table> </table>
{{end}} {{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}}
{{end}} {{end}}
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script> <script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}} {{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
<script src="/_/page/tasks.js"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
<script src="/_/page/sections.js"></script> <script src="{{asset "page/sections.js"}}"></script>
{{end}} {{end}}
<script src="/_/page/contents.js"></script> <script src="{{asset "page/contents.js"}}"></script>
{{end}} {{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}} {{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
-24
View File
@@ -1,24 +0,0 @@
(function () {
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
cb.addEventListener('change', function () {
var idx = cb.dataset.taskIndex;
var checked = cb.checked;
cb.disabled = true;
fetch(window.location.pathname + '?toggle=' + idx, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'checked=' + checked
}).then(function (res) {
if (!res.ok) {
cb.checked = !checked;
alert('Failed to save task state (' + res.status + ')');
}
}).catch(function () {
cb.checked = !checked;
alert('Failed to save task state');
}).finally(function () {
cb.disabled = false;
});
});
});
})();
+33 -27
View File
@@ -1,32 +1,38 @@
document.addEventListener("DOMContentLoaded", function () { // Runs on DOMContentLoaded for a full page load, and right away when this
var content = document.querySelector("main"); // script arrives with a boosted navigation (the document is loaded then).
if (!content) return; (function () {
function run() {
var content = document.querySelector("main");
if (!content) return;
var headings = content.querySelectorAll("h2, h3, h4"); var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return; if (headings.length < 2) return;
var nav = document.createElement("nav"); var nav = document.createElement("nav");
nav.className = "toc panel"; nav.className = "toc panel";
var header = document.createElement("div"); var header = document.createElement("div");
header.className = "panel-header"; header.className = "panel-header";
header.textContent = "Contents"; header.textContent = "Contents";
nav.appendChild(header); nav.appendChild(header);
var list = document.createElement("ul"); var list = document.createElement("ul");
headings.forEach(function (h) { headings.forEach(function (h) {
if (!h.id) return; if (!h.id) return;
var li = document.createElement("li"); var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase(); li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a"); var a = document.createElement("a");
a.href = "#" + h.id; a.href = "#" + h.id;
var clone = h.cloneNode(true); var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); }); clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim(); a.textContent = clone.textContent.trim();
li.appendChild(a); li.appendChild(a);
list.appendChild(li); list.appendChild(li);
}); });
nav.appendChild(list); nav.appendChild(list);
content.insertBefore(nav, content.firstChild); content.insertBefore(nav, content.firstChild);
}); }
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
-85
View File
@@ -1,85 +0,0 @@
// View-settings modal: lets the user pick the folder listing's view style,
// sort key, and order, then persists them by POSTing to the folder with
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
function openViewSettings() {
var btn = document.getElementById('view-settings-btn');
var state = {
view: (btn && btn.dataset.view) || 'list',
sort: (btn && btn.dataset.sort) || 'name',
order: (btn && btn.dataset.order) || 'asc'
};
// segmented builds a row of mutually-exclusive .btn toggles bound to a
// single state key, marking the current choice with .is-active.
function segmented(key, options) {
var wrap = document.createElement('div');
wrap.className = 'row gap-1';
options.forEach(function (opt) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () {
state[key] = opt.value;
wrap.querySelectorAll('button').forEach(function (x) {
x.classList.remove('is-active');
});
b.classList.add('is-active');
});
wrap.appendChild(b);
});
return wrap;
}
function field(labelText, control) {
var row = document.createElement('div');
row.className = 'col gap-1';
var label = document.createElement('span');
label.className = 'caption';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
var sortSelect = document.createElement('select');
sortSelect.className = 'input';
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
if (state.sort === o[0]) opt.selected = true;
sortSelect.appendChild(opt);
});
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
var body = document.createElement('div');
body.className = 'col';
body.appendChild(field('View style', segmented('view', [
{ value: 'list', label: 'List' },
{ value: 'thumbnail', label: 'Thumbnail' }
])));
body.appendChild(field('Sort by', sortSelect));
body.appendChild(field('Order', segmented('order', [
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' }
])));
openModal({
title: 'View settings',
body: body,
confirm: {
label: 'SAVE',
onConfirm: function () {
var action = window.location.pathname + '?settings';
var formBody = 'view=' + encodeURIComponent(state.view) +
'&sort=' + encodeURIComponent(state.sort) +
'&order=' + encodeURIComponent(state.order);
var target = window.location.pathname;
closeModal();
postReplace(action, formBody, target);
}
}
});
}
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Save link</title> <title>Save link</title>
<link rel="icon" href="/_/favicon.ico" /> <link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="stylesheet" href="/_/style.css" /> <link rel="stylesheet" href="{{asset "style.css"}}" />
<style> <style>
body { padding: 0.8rem; } body { padding: 0.8rem; }
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; } .qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
+10 -5
View File
@@ -45,17 +45,22 @@
} }
// Reading mode: keep the latest scroll fraction recorded (throttled to one // Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens. // write per frame) so it is ready the moment the editor opens. Boosted
// navigation replaces .center and changes the page without a reload, so
// listen on document and resolve both when writing.
var pending = false; var pending = false;
center.addEventListener('scroll', function () { document.addEventListener('scroll', function (e) {
if (pending) return; if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
pending = true; pending = true;
requestAnimationFrame(function () { requestAnimationFrame(function () {
pending = false; pending = false;
center = document.querySelector('.center');
if (!center) return;
var m = maxScroll(); var m = maxScroll();
try { try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() })); sessionStorage.setItem('scrollsync:' + location.pathname,
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {} } catch (e) {}
}); });
}, { passive: true }); }, { capture: true, passive: true });
})(); })();
+73 -246
View File
@@ -1,63 +1,18 @@
// search-suggest.js — instant typeahead dropdown. // search-suggest.js — instant typeahead dropdown.
// //
// Exposes window.attachSuggestions(inputEl, opts) used by both the header // Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Owns: debounced fetching, // search box and the editor's "Insert link" modal. Fetching is htmx: the input
// request ordering, DOM creation, keyboard handling, open/close lifecycle. // requests /_search as you type (debounced; a newer request replaces an
// in-flight one) and the server renders the rows (assets/search/suggest.html).
// This file owns the dropdown's open/close lifecycle and keyboard handling.
// //
// opts: // opts:
// onPick(result) — called when the user selects a row // onPick({name}) — optional; called when the user selects a row.
// onShowAll(query) — optional; called when the footer row activates // Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row // showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode) // container (Element) — optional parent (defaults to inputEl.parentNode)
(function () { (function () {
var DEBOUNCE_MS = 100; var seq = 0;
var MIN_QUERY_LEN = 2;
function escapeHTML(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function tokenize(s) {
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
// highlight bolds the substring spans in `name` that match any of the
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
// Returns a safe HTML string.
function highlight(name, tokens) {
if (!tokens.length) return escapeHTML(name);
var lower = name.toLowerCase();
var spans = [];
tokens.forEach(function (t) {
if (!t) return;
var idx = lower.indexOf(t);
if (idx >= 0) spans.push([idx, idx + t.length]);
});
if (!spans.length) return escapeHTML(name);
spans.sort(function (a, b) { return a[0] - b[0]; });
var merged = [spans[0].slice()];
for (var i = 1; i < spans.length; i++) {
var last = merged[merged.length - 1];
if (spans[i][0] <= last[1]) {
last[1] = Math.max(last[1], spans[i][1]);
} else {
merged.push(spans[i].slice());
}
}
var out = '';
var cursor = 0;
merged.forEach(function (sp) {
out += escapeHTML(name.slice(cursor, sp[0]));
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
cursor = sp[1];
});
out += escapeHTML(name.slice(cursor));
return out;
}
function attachSuggestions(inputEl, opts) { function attachSuggestions(inputEl, opts) {
if (!inputEl) return; if (!inputEl) return;
@@ -68,237 +23,116 @@
var dropdown = document.createElement('div'); var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown'; dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown); host.appendChild(dropdown);
function makeRow(cls, tabbable) { // htmx sends the input's value under its name.
var tr = document.createElement('tr'); if (!inputEl.name) inputEl.name = 'q';
tr.className = cls; inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
if (tabbable) tr.setAttribute('tabindex', '0'); inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
var td = document.createElement('td'); inputEl.setAttribute('hx-sync', 'this:replace');
tr.appendChild(td); inputEl.setAttribute('hx-target', '#' + dropdown.id);
return { tr: tr, td: td }; htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
} }
var state = { function isOpen() {
results: [], return dropdown.classList.contains('is-open');
total: 0,
query: '',
activeIdx: -1,
open: false,
reqSeq: 0,
debounceTimer: null,
blurTimer: null,
};
function rowCount() {
var n = state.results.length;
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
return 0; // "no matches" row is non-interactive
}
if (opts.showFooter && state.total > state.results.length) n += 1;
return n;
}
function isFooterIdx(idx) {
return opts.showFooter
&& state.total > state.results.length
&& idx === state.results.length;
}
function render() {
dropdown.textContent = '';
if (!state.open) {
dropdown.classList.remove('is-open');
return;
}
var table = document.createElement('table');
table.className = 'data-table';
var tbody = document.createElement('tbody');
table.appendChild(tbody);
var tokens = tokenize(state.query);
if (state.results.length === 0) {
var empty = makeRow('is-empty', false);
empty.td.textContent = 'No matches';
tbody.appendChild(empty.tr);
} else {
state.results.forEach(function (r, i) {
var row = makeRow('suggest-row', true);
row.tr.setAttribute('data-idx', String(i));
var nameEl = document.createElement('span');
nameEl.className = 'suggest-name';
nameEl.innerHTML = highlight(r.name, tokens);
var pathEl = document.createElement('span');
pathEl.className = 'suggest-path';
pathEl.textContent = '/' + r.path;
row.td.appendChild(nameEl);
row.td.appendChild(pathEl);
if (i === state.activeIdx) row.tr.classList.add('is-active');
row.tr.addEventListener('mousedown', function (e) {
// mousedown (not click) so the input doesn't blur-close
// the dropdown before the pick handler fires.
e.preventDefault();
pick(i);
});
tbody.appendChild(row.tr);
});
if (opts.showFooter && state.total > state.results.length) {
var footer = makeRow('suggest-row suggest-footer', true);
footer.td.textContent = 'Show all ' + state.total + ' matches';
var footerIdx = state.results.length;
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
footer.tr.addEventListener('mousedown', function (e) {
e.preventDefault();
pickFooter();
});
tbody.appendChild(footer.tr);
}
}
dropdown.appendChild(table);
dropdown.classList.add('is-open');
}
function pick(idx) {
var r = state.results[idx];
if (!r) return;
close();
if (opts.onPick) opts.onPick(r);
}
function pickFooter() {
close();
if (opts.onShowAll) {
opts.onShowAll(state.query);
} else if (inputEl.form) {
inputEl.form.submit();
} else {
window.location.href = '/?q=' + encodeURIComponent(state.query);
}
} }
function open() { function open() {
state.open = true; if (dropdown.firstElementChild) dropdown.classList.add('is-open');
render();
} }
function close() { function close() {
state.open = false; dropdown.classList.remove('is-open');
state.activeIdx = -1; setActive(-1);
render();
} }
function fetchResults(query) { function setActive(idx) {
var seq = ++state.reqSeq; var rs = rows();
fetch('/_search?q=' + encodeURIComponent(query), { if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
credentials: 'same-origin', activeIdx = idx;
headers: { 'Accept': 'application/json' }, if (rs[idx]) {
}).then(function (r) { rs[idx].classList.add('is-active');
if (!r.ok) throw new Error('HTTP ' + r.status); try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
return r.json();
}).then(function (resp) {
if (seq !== state.reqSeq) return; // stale
state.results = resp.results || [];
state.total = resp.total || 0;
state.query = resp.query || query;
state.activeIdx = -1;
open();
}).catch(function () {
if (seq !== state.reqSeq) return;
state.results = [];
state.total = 0;
close();
});
}
function onInput() {
var q = inputEl.value.trim();
state.query = q;
if (state.debounceTimer) clearTimeout(state.debounceTimer);
if (q.length < MIN_QUERY_LEN) {
state.reqSeq++; // invalidate any in-flight response
state.results = [];
state.total = 0;
close();
return;
} }
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
} }
function moveActive(delta) { function moveActive(delta) {
var n = rowCount(); var n = rows().length;
if (n === 0) return; if (n === 0) return;
var next = state.activeIdx + delta; var next = activeIdx + delta;
if (next < 0) next = n - 1; if (next < 0) next = n - 1;
if (next >= n) next = 0; if (next >= n) next = 0;
state.activeIdx = next; setActive(next);
render(); }
// Keep the active row in view.
var active = dropdown.querySelector('tr.is-active'); // pick activates a row: onPick for data rows when given, otherwise
if (active && active.scrollIntoView) { // (and always for the footer) the row's link.
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} function pick(row) {
close();
if (opts.onPick && !row.classList.contains('suggest-footer')) {
opts.onPick({ name: row.dataset.name });
} else {
row.querySelector('a').click();
} }
} }
function activateCurrent() { dropdown.addEventListener('htmx:afterSwap', function () {
if (state.activeIdx < 0) return false; activeIdx = -1;
if (isFooterIdx(state.activeIdx)) { if (document.activeElement === inputEl && dropdown.firstElementChild) {
pickFooter();
return true;
}
pick(state.activeIdx);
return true;
}
inputEl.addEventListener('input', onInput);
inputEl.addEventListener('focus', function () {
if (state.blurTimer) {
clearTimeout(state.blurTimer);
state.blurTimer = null;
}
if (inputEl.value.trim().length >= MIN_QUERY_LEN
&& (state.results.length || state.query)) {
open(); open();
} else {
close();
} }
}); });
inputEl.addEventListener('blur', function () {
// Delay so click/mousedown on a row still resolves. // mousedown keeps focus in the input so blur doesn't close the
state.blurTimer = setTimeout(close, 150); // dropdown before the click lands.
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
dropdown.addEventListener('click', function (e) {
var row = e.target.closest('.suggest-row');
if (!row) return;
if (opts.onPick && !row.classList.contains('suggest-footer')) {
e.preventDefault();
pick(row);
} else {
close(); // the link navigates
}
}); });
inputEl.addEventListener('focus', open);
inputEl.addEventListener('blur', close);
inputEl.addEventListener('keydown', function (e) { inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(1); moveActive(1);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(-1); moveActive(-1);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
close(); close();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (state.open && state.activeIdx >= 0) { // Without an active row: native submit (full results page).
var row = rows()[activeIdx];
if (row) {
e.preventDefault(); e.preventDefault();
activateCurrent(); pick(row);
} }
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
if (!state.open || rowCount() === 0) return; if (rows().length === 0) return;
e.preventDefault(); e.preventDefault();
moveActive(e.shiftKey ? -1 : 1); moveActive(e.shiftKey ? -1 : 1);
} }
}); });
// Click outside the host closes the dropdown.
document.addEventListener('mousedown', function (e) {
if (!state.open) return;
if (host.contains(e.target)) return;
close();
});
return { return {
close: close, close: close,
destroy: function () { destroy: function () {
@@ -310,14 +144,7 @@
window.attachSuggestions = attachSuggestions; window.attachSuggestions = attachSuggestions;
// Auto-bind to the header search input. Header search submits the form
// for the "show all" action; we route to a navigate-on-pick handler.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('header .search-input'); attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
}); });
})(); })();
+21 -12
View File
@@ -52,19 +52,19 @@ function createSearchPage(name) {
}); });
} }
// The "create page" link on results pages; delegated, since boosted
// navigation swaps the results in without a new DOMContentLoaded.
document.addEventListener('click', function (e) {
var createLink = e.target.closest && e.target.closest('[data-create-page]');
if (!createLink) return;
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM order,
// so the natural tab sequence is input → first result → next, …
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM
// order, so the natural tab sequence is input → first result → next, …
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (input && input.value) { if (input && input.value) {
input.focus(); input.focus();
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
try { input.setSelectionRange(end, end); } catch (e) {} try { input.setSelectionRange(end, end); } catch (e) {}
} }
}); });
// The header (and its search box) survives boosted navigation: show the
// current page's query, as a full load would.
document.addEventListener('page:swapped', function () {
var input = document.querySelector('.search-input');
if (input && document.activeElement !== input) {
input.value = new URLSearchParams(location.search).get('q') || '';
}
});
+1 -3
View File
@@ -1,5 +1,3 @@
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}} {{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}} {{define "content"}}
@@ -43,7 +41,7 @@
{{end}} {{end}}
{{define "headerActions"}} {{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}} {{end}}
{{define "footerExtras"}} {{define "footerExtras"}}
+8
View File
@@ -0,0 +1,8 @@
{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a
pick in the header is plain navigation; the editor's link dialog intercepts
the click and reads data-name. Empty output closes the dropdown. */}}
{{define "suggest"}}{{if .Show}}<table class="data-table"><tbody>
{{range .Results}}<tr class="suggest-row" data-name="{{.Name}}"><td><a class="suggest-link" href="{{.URL}}" tabindex="-1"><span class="suggest-name">{{.Highlighted}}</span><span class="suggest-path">/{{.Path}}</span></a></td></tr>
{{else}}<tr class="is-empty"><td>No matches</td></tr>
{{end}}{{if .AllURL}}<tr class="suggest-row suggest-footer"><td><a class="suggest-link" href="{{.AllURL}}" tabindex="-1">Show all {{.Total}} matches</a></td></tr>
{{end}}</tbody></table>{{end}}{{end}}
+122 -11
View File
@@ -48,6 +48,28 @@
--reading-width: 60rem; --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 /* 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), scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */ so no html-level scroll padding is needed. */
@@ -219,6 +241,20 @@ footer {
.danger { color: var(--danger); } .danger { color: var(--danger); }
.danger:hover { color: var(--danger-hover); } .danger:hover { color: var(--danger-hover); }
.btn.is-active { color: var(--primary-hover); } .btn.is-active { color: var(--primary-hover); }
/* Segmented choice: radios styled as .btn toggles. The label is the button;
the input stays in the accessibility tree and keyboard order. */
.segmented input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.segmented .btn { position: relative; }
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
/* === Form controls === /* === Form controls ===
.input baseline is shared by search-input, modal inputs, and the editor .input baseline is shared by search-input, modal inputs, and the editor
@@ -389,6 +425,12 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; } .dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;} .actions-special > .btn {text-align: left;}
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
.page-actions { display: contents; }
/* 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) === /* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -407,11 +449,13 @@ main > h2 {
} }
.suggest-dropdown.is-open { display: block; } .suggest-dropdown.is-open { display: block; }
.suggest-row { cursor: pointer; } .suggest-row { cursor: pointer; }
.suggest-row > td { padding: 0.4rem 0.6rem; } .suggest-row > td { padding: 0; }
/* The link fills the row, so every part of it is the click target. */
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
.suggest-name, .suggest-path { display: block; } .suggest-name, .suggest-path { display: block; }
.suggest-name { color: var(--text); } .suggest-name { color: var(--text); }
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; } .suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); } .suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); }
/* === Editor toolbar === /* === Editor toolbar ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
@@ -707,17 +751,33 @@ aside.sidebar:empty { display: none; }
padding: 0.25rem var(--space-1); padding: 0.25rem var(--space-1);
cursor: pointer; cursor: pointer;
} }
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); } .tree-row:hover { background: var(--bg-panel-hover); }
.tree-row.is-selected {
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row.is-active > .tree-name { color: var(--secondary); } .tree-row.is-active > .tree-name { color: var(--secondary); }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; } .tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; } .tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; } .tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); } .tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; } .tree-chevron.is-leaf { visibility: hidden; }
/* Expandable folders are <details>; the chevron follows its open state. */
.tree-node > summary { list-style: none; }
.tree-node > summary::-webkit-details-marker { display: none; }
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
/* Picker rows select through a visually hidden radio in the name label. */
.tree-name input[type="radio"] {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.tree-row:has(input:checked) {
background: var(--bg-panel-hover);
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
label.tree-name { flex: 1; cursor: pointer; position: relative; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-children { .tree-children {
margin-left: var(--space-2); margin-left: var(--space-2);
@@ -797,13 +857,46 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current, .diary-cal-grid td.cal-current,
.diary-cal-grid td.cal-current a { color: var(--primary-hover); } .diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { 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 === /* === Fitness dashboard ===
Server-rendered inline SVG charts. Geometry comes precomputed from Go; Server-rendered inline SVG charts. Geometry comes precomputed from Go;
colors and strokes are applied here via classes so the inline SVG follows colors and strokes are applied here via classes so the inline SVG follows
the theme palette. */ 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-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-range { width: auto; font-size: var(--font-sm); }
.fitness-empty { .fitness-empty {
border: var(--border-dashed); border: var(--border-dashed);
@@ -817,8 +910,24 @@ aside.sidebar:empty { display: none; }
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); } .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-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
.fitness-svg .chart-dot { fill: var(--link); } .fitness-svg .chart-dot { fill: var(--link); }
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; } .fitness-svg.is-dense .chart-dot { display: none; }
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); } .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 === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -840,7 +949,9 @@ aside.sidebar:empty { display: none; }
@media (min-width: 1101px) { @media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;} .actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;} .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) { @media (max-width: 600px) {
+31 -247
View File
@@ -1,43 +1,29 @@
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
// radio named "pick", folders expand natively and lazy-load via htmx. This
// file only hosts the tree in a modal and reports the checked row.
//
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
// (default true: offer "/" in folder modes), onSelect(path, kind).
(function () { (function () {
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
}
function encodePath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
}
function fetchFolder(path) {
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function openTreePicker(opts) { function openTreePicker(opts) {
opts = opts || {}; opts = opts || {};
var mode = opts.mode || 'folder'; var mode = opts.mode || 'folder';
var initialPath = opts.initialPath || '/';
var allowRoot = opts.allowRoot !== false;
var hideFiles = !!opts.hideFiles;
var preselect = opts.preselect || null;
var container = document.createElement('div'); var container = document.createElement('div');
var treeEl = document.createElement('div'); var treeEl = document.createElement('div');
treeEl.className = 'tree-picker panel'; treeEl.className = 'tree-picker panel';
treeEl.textContent = '…';
var selectedPathEl = document.createElement('div'); var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted'; selectedPathEl.className = 'tree-selected-path muted';
selectedPathEl.textContent = '\u00a0'; selectedPathEl.textContent = ' ';
container.appendChild(treeEl); container.appendChild(treeEl);
container.appendChild(selectedPathEl); container.appendChild(selectedPathEl);
var selected = null; // { path, kind, rowEl } function checked() {
return treeEl.querySelector('input[name="pick"]:checked');
}
var handle = openModal({ var handle = openModal({
title: opts.title || 'Pick', title: opts.title || 'Pick',
@@ -46,236 +32,34 @@
label: opts.confirmLabel || 'SELECT', label: opts.confirmLabel || 'SELECT',
initiallyDisabled: true, initiallyDisabled: true,
onConfirm: function () { onConfirm: function () {
if (!selected) return; var pick = checked();
if (!pick) return;
handle.close(); handle.close();
if (opts.onSelect) opts.onSelect(selected.path, selected.kind); if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
} }
} }
}); });
function setSelected(path, kind, rowEl) { function sync() {
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected'); var pick = checked();
selected = { path: path, kind: kind, rowEl: rowEl }; selectedPathEl.textContent = pick ? pick.value : ' ';
rowEl.classList.add('is-selected'); handle.setConfirmDisabled(!pick);
selectedPathEl.textContent = path;
handle.setConfirmDisabled(false);
} }
treeEl.addEventListener('change', sync);
function isSelectable(kind) { var url = '/?tree&pick=' + mode +
if (mode === 'any') return true; (opts.hideFiles ? '' : '&files=1') +
if (mode === 'folder') return kind === 'folder'; (opts.allowRoot !== false ? '&root=1' : '') +
if (mode === 'file') return kind === 'file'; '&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
return false; (opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
} htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
sync();
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op var pick = checked();
// for files and idempotent for folders (resolves with the already- if (pick) {
// loaded children on repeat calls). try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
function buildRow(parentPath, name, kind) {
var row = document.createElement('div');
row.className = 'tree-row';
if (!isSelectable(kind)) row.classList.add('is-disabled');
var chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (kind === 'folder') {
chevron.textContent = '\u25b8'; // ▸
} else {
chevron.classList.add('is-leaf');
} }
row.appendChild(chevron);
var label = document.createElement('span');
label.className = 'tree-name';
label.textContent = name;
row.appendChild(label);
var fullPath = joinPath(parentPath, name);
var childrenEl = null;
var loadPromise = null;
var isOpen = false;
function expand() {
if (kind !== 'folder') return Promise.resolve(null);
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
row.parentNode.insertBefore(childrenEl, row.nextSibling);
chevron.textContent = '\u25be'; // ▾
isOpen = true;
if (!loadPromise) {
loadPromise = loadInto(fullPath, childrenEl);
}
return loadPromise;
}
function collapse() {
if (kind !== 'folder' || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '\u25b8';
isOpen = false;
}
chevron.addEventListener('click', function (e) {
e.stopPropagation();
if (isOpen) collapse(); else expand();
});
row.addEventListener('click', function () {
if (isSelectable(kind)) {
setSelected(fullPath, kind, row);
} else if (kind === 'folder') {
if (isOpen) collapse(); else expand();
}
});
if (kind === 'folder') {
row.addEventListener('dblclick', function (e) {
e.preventDefault();
if (isOpen) collapse(); else expand();
});
}
return {
rowEl: row,
name: name,
kind: kind,
childrenEl: function () { return childrenEl; },
expand: expand
};
}
// loadInto fetches folderPath and populates `target` with rows. Returns
// an array of the row objects on success, or [] on failure.
function loadInto(folderPath, target) {
target.textContent = '';
var loading = document.createElement('div');
loading.className = 'tree-row is-disabled';
loading.textContent = '\u2026';
target.appendChild(loading);
return fetchFolder(folderPath).then(function (resp) {
target.textContent = '';
var rows = [];
(resp.entries || []).forEach(function (e) {
if (hideFiles && e.kind !== 'folder') return;
var r = buildRow(resp.path, e.name, e.kind);
target.appendChild(r.rowEl);
rows.push(r);
});
if (rows.length === 0) {
var empty = document.createElement('div');
empty.className = 'tree-row is-disabled';
empty.textContent = '(empty)';
target.appendChild(empty);
}
return rows;
}).catch(function () {
target.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () {
loadInto(folderPath, target);
});
target.appendChild(err);
return [];
});
}
// Root selection row — visible when allowRoot and mode accepts folders.
if (allowRoot && isSelectable('folder')) {
var rootRow = document.createElement('div');
rootRow.className = 'tree-row';
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
rootRow.addEventListener('click', function () {
setSelected('/', 'folder', rootRow);
});
treeEl.appendChild(rootRow);
}
var rootChildren = document.createElement('div');
treeEl.appendChild(rootChildren);
// Expand the ancestor chain of initialPath so the user lands in
// context. For root, just load root children.
var segs = (initialPath || '/').split('/').filter(Boolean);
var preselectSegs = preselect
? preselect.split('/').filter(Boolean)
: null;
loadInto('/', rootChildren).then(function (rows) {
return expandChain(rows, segs, '/');
}).then(function () {
if (preselectSegs === null) return;
if (preselectSegs.length === 0) {
// Preselect root itself (if allowed).
if (allowRoot && isSelectable('folder')) {
var root = treeEl.querySelector('.tree-row');
if (root) setSelected('/', 'folder', root);
}
return;
}
selectByPath(preselectSegs);
}); });
// expandChain walks `segments`, looking up each by name in the current
// row list, expanding it, and recursing into its children.
function expandChain(rows, segments, basePath) {
if (segments.length === 0) return Promise.resolve();
var seg = segments[0];
var match = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].kind === 'folder' && rows[i].name === seg) {
match = rows[i];
break;
}
}
if (!match) return Promise.resolve();
return match.expand().then(function (childRows) {
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
});
}
// selectByPath walks the visible tree rows to locate the row matching
// `segments` and marks it selected. Assumes its ancestors are already
// expanded (expandChain ran first).
function selectByPath(segments) {
var container = rootChildren;
var path = '/';
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var kids = container.children;
var found = null;
for (var j = 0; j < kids.length; j++) {
var row = kids[j];
if (!row.classList || !row.classList.contains('tree-row')) continue;
var nm = row.querySelector('.tree-name');
if (nm && nm.textContent === seg) { found = row; break; }
}
if (!found) return;
path = joinPath(path, seg);
if (i === segments.length - 1) {
if (isSelectable('folder')) setSelected(path, 'folder', found);
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
return;
}
var next = found.nextSibling;
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
container = next;
}
}
return handle; return handle;
} }
-215
View File
@@ -1,215 +0,0 @@
;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return
function joinPath(parent, name) {
if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, "") + "/" + name
}
function encodeSegments(p) {
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
}
function folderHref(p) {
if (p === "/" || p === "") return "/"
return "/" + encodeSegments(p) + "/"
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status)
return r.json()
})
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, "")
if (p === "") return "/"
try {
return decodeURIComponent(p)
} catch (e) {
return p
}
})()
var activeRow = null
function disabledRow(text) {
var row = document.createElement("div")
row.className = "tree-row is-disabled"
row.textContent = text
return row
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row)
if (built.preExpand) built.open()
})
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name)
var row = document.createElement("div")
row.className = "tree-row"
var chevron = document.createElement("span")
chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var link = document.createElement("a")
link.className = "tree-name tree-folder"
link.textContent = entry.name
link.href = folderHref(fullPath)
row.appendChild(link)
if (fullPath === activePath) {
row.classList.add("is-active")
activeRow = row
}
var preChildren = entry.children ? entry.children : null
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = false
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement("div")
childrenEl.className = "tree-children"
}
return childrenEl
}
function markLeaf() {
isLeaf = true
isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
}
function loadChildren() {
var el = ensureChildrenEl()
el.textContent = ""
el.appendChild(disabledRow("…"))
fetchFolder(fullPath)
.then(function (resp) {
el.textContent = ""
loaded = true
if (!resp.entries || resp.entries.length === 0) {
markLeaf()
return
}
renderInto(el, fullPath, resp.entries)
})
.catch(function () {
el.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
}
function open() {
if (isOpen || isLeaf) return
var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = "▾" // ▾
isOpen = true
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren)
loaded = true
} else {
loadChildren()
}
}
}
function close() {
if (!isOpen) return
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
chevron.textContent = "▸"
isOpen = false
}
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener("click", function (e) {
if (e.target.closest("a.tree-name")) return
if (isOpen) close()
else open()
})
return { row: row, open: open, preExpand: !!preChildren }
}
function render() {
container.textContent = ""
var listing = document.createElement("div")
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
}
})
.catch(function () {
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
render()
})
listing.appendChild(err)
})
}
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
var item = document.createElement("button")
item.type = "button"
item.className = "btn btn-block"
item.title = "Folders"
item.textContent = "FOLDERS"
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container)
})
specials.appendChild(item)
}
render()
setupMenuItem()
})()
+18
View File
@@ -0,0 +1,18 @@
{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
Expandable folders are native <details>: open/close needs no script, and a
folder's children load via htmx the first time it opens, unless the server
pre-expanded them. A label inside <summary> selects its radio without
toggling the folder; the rest of the row toggles. */}}
{{define "rows"}}{{range .}}{{if .Expandable}}<details class="tree-node"{{if .Children}} open{{end}}>
<summary class="{{template "row-class" .}}"><span class="tree-chevron" aria-hidden="true"></span>{{template "label" .}}</summary>
<div class="tree-children"{{if not .Children}} hx-get="{{.ChildrenURL}}" hx-trigger="toggle once from:closest details"{{end}}>{{template "rows" .Children}}</div>
</details>
{{else}}<div class="{{template "row-class" .}}"><span class="tree-chevron is-leaf" aria-hidden="true"></span>{{template "label" .}}</div>
{{end}}{{end}}{{end}}
{{define "row-class"}}tree-row{{if .Active}} is-active{{end}}{{if and (not .Link) (not .Selectable)}} is-disabled{{end}}{{end}}
{{define "label"}}{{if .Link}}<a class="tree-name tree-{{.Kind}}" href="{{.URL}}">{{.Name}}</a>
{{- else if .Selectable}}<label class="tree-name"><input type="radio" name="pick" value="{{.Path}}" data-kind="{{.Kind}}"{{if .Checked}} checked{{end}} />{{.Name}}</label>
{{- else}}<span class="tree-name">{{.Name}}</span>{{end}}{{end}}
+1
View File
File diff suppressed because one or more lines are too long
+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
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"html/template"
"log"
"net/http"
"path"
"strings"
)
var dialogTmpl = template.Must(template.New("dialogs").ParseFS(assets, "assets/page/dialogs.html"))
// isHX reports whether r was issued by htmx.
func isHX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// layoutTemplate names the template that renders a layout page for r: the
// whole document, or for boosted navigation and htmx history restores only
// the "partial" that hx.js swaps into the open page. The editor is never
// boosted.
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
return "partial"
}
return "layout"
}
// redirectAfter sends the client to target once a mutation succeeded. htmx
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
// Back skips the pre-mutation page); plain form posts get a 303.
func redirectAfter(w http.ResponseWriter, r *http.Request, target string) {
if isHX(r) {
w.Header().Set("HX-Redirect", target)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// pageURL returns the escaped, slash-terminated browser URL of the page at
// the wiki path p ("/" or "/a/b", trailing slash optional).
func pageURL(p string) string {
p = strings.Trim(p, "/")
if p == "" {
return "/"
}
return fileURL(p) + "/"
}
// renderDialog writes the named dialog from dialogs.html.
func renderDialog(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := dialogTmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("dialog template error: %v", err)
}
}
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
page := normalizeMovePath(urlPath)
name := r.URL.Query().Get("dialog")
if page == "/" && (name == "delete" || name == "move") {
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
return
}
switch name {
case "view":
view, sortKey, order := readPageSettings(fsPath).viewSettings()
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
case "delete":
renderDialog(w, "delete", struct{ Path string }{urlPath})
case "move":
// parent comes from the folder picker; default to the current one.
parent := r.URL.Query().Get("parent")
if parent == "" {
parent = path.Dir(page)
}
renderDialog(w, "move", struct{ Parent, Name string }{parent, path.Base(page)})
default:
http.NotFound(w, r)
}
}
+26 -1
View File
@@ -335,7 +335,7 @@ type calendarData struct {
Years []calYear 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 { func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now() today := time.Now()
@@ -533,6 +533,8 @@ type diarySection struct {
EditURL string // year-file section edit URL ("" = no edit button) EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line) Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto 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 { type diaryContentData struct {
@@ -623,6 +625,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
out := buildFileSections(sections, photos, yearURL) out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL) out = appendVirtualEntries(out, sections, photos, year, yearURL)
attachCalendarLines(out, year)
return renderDiaryContent(out) return renderDiaryContent(out)
} }
@@ -746,6 +749,28 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
return out 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 // isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry // date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against. // interleave sorts against.
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.tmp/
test-results/
playwright-report/
+58
View File
@@ -0,0 +1,58 @@
{
"name": "datascape-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datascape-e2e",
"devDependencies": {
"@playwright/test": "^1"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "datascape-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.E2E_PORT || "8765";
export default defineConfig({
testDir: "tests",
// Tests share one server and wiki but each works in its own sandbox folder,
// so they can run in parallel.
workers: 4,
fullyParallel: true,
reporter: "list",
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
// The system Chrome; no browser download needed.
{ name: "chrome", use: { channel: "chrome" } },
// Playwright's Firefox build: npx playwright install firefox
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
webServer: {
command: "node server.mjs",
url: `http://127.0.0.1:${port}/`,
timeout: 120_000,
reuseExistingServer: false,
stdout: "pipe",
},
});
+27
View File
@@ -0,0 +1,27 @@
// Builds datascape, seeds a throwaway wiki and runs the server in the
// foreground. Playwright's webServer starts this and kills it after the run.
import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tmp = join(here, ".tmp");
const wiki = join(tmp, "wiki");
const bin = join(tmp, process.platform === "win32" ? "datascape.exe" : "datascape");
const port = process.env.E2E_PORT || "8765";
rmSync(tmp, { recursive: true, force: true });
mkdirSync(wiki, { recursive: true });
execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" });
// Tests create their own pages (see sandbox in tests/pages.spec.js); the seed
// is only the home page.
writeFileSync(join(wiki, "index.md"), "# Home\n\nWelcome.\n");
const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], {
stdio: "inherit",
});
srv.on("exit", (code) => process.exit(code ?? 0));
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => srv.kill());
+155
View File
@@ -0,0 +1,155 @@
// Boosted navigation: outside the editor, page links swap the next page into
// the open document instead of loading a new one. A marker on window survives
// a boosted navigation and disappears with a full page load.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
// Chrome aborts the cross-document view transition (style.css) when the
// next document can't take part, e.g. a plain-text file, and reports it
// as an unhandled rejection that no page script can catch.
if (err.message.includes("ViewTransition opt-in disabled")) return;
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
function sandbox(pages) {
const root = "t" + randomWord(7);
for (const [rel, body] of Object.entries(pages)) {
const dir = join(wiki, root, rel);
mkdirSync(dir, { recursive: true });
if (body !== null) writeFileSync(join(dir, "index.md"), body);
}
return root;
}
const sidebar = (page) => page.locator("aside.tree-sidebar");
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
const mark = (page) => page.evaluate(() => (window.__marker = 1));
const marked = (page) => page.evaluate(() => window.__marker === 1);
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
const root = sandbox({
"": "# Parent\n",
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
});
await page.goto(`/${root}/`);
await mark(page);
await link(sidebar(page), "Child").click();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
await expect(page).toHaveTitle("Child");
expect(await marked(page)).toBe(true);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
// Page scripts ran for the new content: TOC and section edit links.
await expect(page.locator("nav.toc")).toContainText("Second");
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
// htmx attributes in the new content work: the task toggles.
await page.locator("main input.task-checkbox").check();
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
});
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
const root = sandbox({ "": long("Parent"), Child: long("Child") });
await page.goto(`/${root}/`);
await mark(page);
const center = page.locator(".center");
await center.evaluate((el) => (el.scrollTop = 600));
await link(sidebar(page), "Child").click();
await expect(page.locator("main h1")).toContainText("Child");
// A new page starts at the top.
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page).toHaveTitle("Parent");
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
await page.goForward();
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("Back to the first, fully loaded page works", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto(`/${root}/Child/`);
await mark(page);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("files and the editor load as full pages", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
writeFileSync(join(wiki, root, "note.txt"), "plain file");
await page.goto(`/${root}/`);
await mark(page);
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
await expect(page.locator("body")).toContainText("plain file");
expect(await marked(page)).toBe(false);
await page.goto(`/${root}/`);
await mark(page);
await page.locator("header").getByTitle("Edit page (E)").click();
await expect(page).toHaveURL(/\?edit/);
await expect(page.locator(".cm-content")).toBeVisible();
expect(await marked(page)).toBe(false);
});
test("header actions follow the page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
await page.goto("/");
await mark(page);
const move = page.locator("header").getByTitle("Move page (M)");
await expect(move).toHaveCount(0);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(move).toBeVisible();
await page.goBack();
await expect(page.locator("main h1")).toContainText("Home");
await expect(move).toHaveCount(0);
expect(await marked(page)).toBe(true);
});
test("search results and the search box follow boosted navigation", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
expect((await page.request.post("/_reindex")).status()).toBe(204);
await page.goto(`/${root}/`);
await mark(page);
const input = page.locator("header .search-input");
await input.fill(root);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
await expect(page).toHaveTitle(`Search: ${root}`);
await expect(page.locator("main .search-card").first()).toBeVisible();
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(input).toHaveValue("");
expect(await marked(page)).toBe(true);
});
test("a mutation swaps in its result without a reload", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await mark(page);
await page.locator("header").getByTitle("Delete page").click();
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page.locator(".modal")).toHaveCount(0);
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
expect(await marked(page)).toBe(true);
});
+392
View File
@@ -0,0 +1,392 @@
// End-to-end coverage of the page UI. Tests drive the app through visible
// labels, not implementation details, so they survive changes to the
// client-side mechanics.
//
// Every test builds the pages it needs under its own fresh top-level folder
// (sandbox), so tests don't depend on each other's leftovers and can run alone
// or in any order. Names are matched exactly: other tests' folders share the
// sidebar and the search index.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
// Fail any test that logs a JS error.
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
throw err;
});
});
// randomWord returns n random lowercase letters.
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh top-level folder holding pages, keyed by path
// relative to it ("" is the folder's own page); a null body makes an empty
// folder. Returns the folder name.
function sandbox(pages) {
const root = "t" + randomWord(7);
for (const [rel, body] of Object.entries(pages)) {
const dir = join(wiki, root, rel);
mkdirSync(dir, { recursive: true });
if (body !== null) writeFileSync(join(dir, "index.md"), body);
}
return root;
}
const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8");
const exactly = (name) => new RegExp(`^${name}$`);
const sidebar = (page) => page.locator("aside.tree-sidebar");
const modal = (page) => page.locator(".modal");
const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) });
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
// wide ones; address them by title and open the dropdown only when present.
async function pageAction(page, title) {
const toggle = page.getByRole("button", { name: "ACTIONS" });
if (await toggle.isVisible()) await toggle.click();
await page.locator("header").getByTitle(title).click();
}
// movePage runs the move flow from the current page: keep the preselected
// parent, then enter name.
async function movePage(page, name) {
await pageAction(page, "Move page (M)");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(name);
await modal(page).getByRole("button", { name: "MOVE" }).click();
}
test("renders a page and navigates via the sidebar", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto("/");
await expect(page.locator("main h1")).toContainText("Home");
await link(sidebar(page), root).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
// The sidebar expands the active folder's children.
await expect(link(sidebar(page), "Child")).toBeVisible();
});
test("task checkbox toggle persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await expect(box).not.toBeChecked();
await box.check();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [x] first task");
await page.reload();
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
});
test("unchecking a task persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").nth(1);
await expect(box).toBeChecked();
await box.uncheck();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [ ] second task");
});
test("a failed task save reverts the checkbox", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" });
page.on("dialog", (d) => d.dismiss());
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await box.click();
await expect(box).toBeEnabled();
await expect(box).toBeChecked();
expect(readPage(root)).toContain("- [x] done task");
});
test("view settings switch the listing to thumbnails", async ({ page }) => {
const root = sandbox({ "": "# Listing\n", Child: "# Child\n" });
await page.goto(`/${root}/`);
await expect(page.locator("main table.data-table")).toBeVisible();
await page.getByTitle("View & sorting").click();
await modal(page).getByText("Thumbnail").click();
await modal(page).getByLabel("Sort by").selectOption("size");
await modal(page).getByText("Desc").click();
await modal(page).getByRole("button", { name: "SAVE" }).click();
await expect(page.locator("main .thumb-grid")).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
// Reopening shows the saved state.
await page.getByTitle("View & sorting").click();
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
await expect(modal(page).getByLabel("Desc")).toBeChecked();
});
test("editor saves a page", async ({ page }) => {
const root = sandbox({ "": "# Editable\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Edit page (E)");
await expect(page).toHaveURL(/\?edit/);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type("\nAdded by e2e.\n");
await page.getByRole("button", { name: "SAVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main")).toContainText("Added by e2e.");
expect(readPage(root)).toContain("Added by e2e.");
// Save replaces the editor's history entry: Back leaves the page entirely.
await page.goBack();
await expect(page).not.toHaveURL(/\?edit/);
});
test("move renames a page", async ({ page }) => {
const root = sandbox({ Beta: "# Beta\n" });
await page.goto(`/${root}/Beta/`);
await movePage(page, "Gamma");
await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`));
await expect(page.locator("main h1")).toContainText("Beta");
});
test("move onto an empty folder offers a merge", async ({ page }) => {
const root = sandbox({ Alpha: "# Alpha\n", Empty: null });
await page.goto(`/${root}/Alpha/`);
await movePage(page, "Empty");
await expect(modal(page)).toContainText("Merge folders?");
await modal(page).getByRole("button", { name: "MERGE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`));
await expect(page.locator("main h1")).toContainText("Alpha");
});
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" });
await page.goto(`/${root}/Mover/`);
// Dismiss the alert from the handler: an open alert blocks the click.
const messages = [];
page.on("dialog", (d) => {
messages.push(d.message());
d.dismiss();
});
await movePage(page, "Taken");
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
await expect(modal(page)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`));
});
test("delete removes a page and returns to the parent", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" });
await page.goto(`/${root}/Notes/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toContainText(`Delete /${root}/Notes/`);
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(link(sidebar(page), "Notes")).toHaveCount(0);
});
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/`);
await page.goto(`/${root}/Gone/`);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("a server dialog works from the first moment it is shown", async ({ page }) => {
// htmx wires up swapped-in forms only when it settles. Submit in the very
// next task after the dialog is inserted: if the form isn't wired yet, the
// browser submits it natively (a GET) and the page is not deleted.
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await page.evaluate(() => {
document.addEventListener("htmx:afterSwap", (e) => {
if (e.detail.target.id !== "modal-host") return;
const submit = e.detail.target.querySelector('button[type="submit"]');
setTimeout(() => submit.click(), 0);
}, { once: true });
});
await pageAction(page, "Delete page");
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
const root = sandbox({ "": "# Keep me\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toBeVisible();
// CANCEL is focused, so Enter must not delete.
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(modal(page)).toHaveCount(0);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "CANCEL" }).click();
await expect(modal(page)).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
expect(readPage(root)).toContain("Keep me");
});
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
const root = sandbox({ "": "# Here\n" });
const treeRequests = [];
page.on("request", (r) => {
if (r.url().includes("?tree")) treeRequests.push(r.url());
});
const res = await page.request.get(`/${root}/`);
expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`);
await page.goto(`/${root}/`);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root);
expect(treeRequests).toEqual([]);
});
test("sidebar folders expand lazily and collapse", async ({ page }) => {
const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" });
await page.goto("/");
const chevron = treeRow(sidebar(page), root).locator(".tree-chevron");
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
await expect(link(sidebar(page), "One")).toHaveCount(0);
expect(await glyph()).toBe('"▸"');
await chevron.click();
await expect(link(sidebar(page), "One")).toBeVisible();
expect(await glyph()).toBe('"▾"');
// One has a subfolder, Two has none: only One gets a chevron.
await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1);
await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/);
await chevron.click();
await expect(link(sidebar(page), "One")).toBeHidden();
});
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" });
await page.goto(`/${root}/Mover/`);
await pageAction(page, "Move page (M)");
// The picker opens with the current parent expanded; Tree is still closed.
const picker = modal(page).locator(".tree-picker");
await treeRow(picker, "Tree").locator(".tree-chevron").click();
await treeRow(picker, "One").locator(".tree-chevron").click();
await picker.getByText("Deep", { exact: true }).click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`));
});
test("tree picker can pick the root", async ({ page }) => {
const root = sandbox({ Two: "# Two\n" });
await page.goto(`/${root}/Two/`);
await pageAction(page, "Move page (M)");
// The current parent is preselected and its chain expanded.
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`);
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(`${root}-top`);
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`));
});
test("editor embed picker selects a file", async ({ page }) => {
const root = sandbox({ "": "# Embeds\n" });
writeFileSync(join(wiki, root, "photo.jpg"), "x");
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+G");
const picker = modal(page).locator(".tree-picker");
// Folder rows aren't selectable in file mode; the file is.
await picker.getByText("photo.jpg").click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/photo.jpg`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
});
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
const root = sandbox({ "": "# Mobile\n" });
await page.setViewportSize({ width: 400, height: 800 });
await page.goto(`/${root}/`);
await expect(sidebar(page)).toBeHidden();
await page.getByRole("button", { name: "ACTIONS" }).click();
await page.getByTitle("Folders").click();
const overlay = page.locator(".overlay");
await expect(link(overlay, root)).toBeVisible();
await link(overlay, root).click();
await expect(overlay).toHaveCount(0);
});
// seedSearch creates pages whose names share a random word (so no other
// test's pages match it) and reindexes. Returns the sandbox, the word and its
// capitalized form used in the names.
async function seedSearch(page) {
const word = randomWord(8);
const Word = word[0].toUpperCase() + word.slice(1);
const pages = { "": "# Search\n" };
for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) {
pages[`${Word} ${n}`] = `# ${Word} ${n}\n`;
}
const root = sandbox(pages);
expect((await page.request.post("/_reindex")).status()).toBe(204);
return { root, word, Word };
}
const suggest = (page) => page.locator("header .suggest-dropdown");
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(`${word} finch`);
const first = suggest(page).locator(".suggest-row").first();
await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`);
await expect(first.locator("strong")).toHaveText([Word, "Finch"]);
await input.press("ArrowDown");
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`));
});
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(word[0]);
await page.waitForTimeout(300);
await expect(suggest(page)).toBeHidden();
await input.fill(word);
const { total } = await (await page.request.get(`/_search?q=${word}`)).json();
expect(total).toBeGreaterThan(5);
await expect(suggest(page).locator(".suggest-footer")).toHaveText(`Show all ${total} matches`);
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
await input.press("Escape");
await expect(suggest(page)).toBeHidden();
await input.fill(`${word} crossing`);
await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`));
await page.goto("/");
await page.locator("header .search-input").fill(word);
await suggest(page).locator(".suggest-footer").click();
await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`));
});
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+P");
const query = modal(page).getByPlaceholder("Search query");
await query.fill(`${word} crossing`);
await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click();
await expect(query).toHaveValue(`${Word} Zebra Crossing`);
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`);
});
test("editor [[ autocomplete lists matching pages", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type(`\n[[${word} finc`);
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`);
});
+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(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`) w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name))) w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`) w.WriteString(`" loading="lazy"></a>`)
if captioned { if captioned {
w.WriteString(`<figcaption>`) w.WriteString(`<figcaption>`)
} }
+171 -171
View File
@@ -2,14 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"math" "math"
"net/http" "net/http"
"os" "net/url"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -20,10 +18,6 @@ func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{}) 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{} type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders // 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 // handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so // 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 { func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath) s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" { if s == nil || s.Type != "fitness" {
return nil return nil
} }
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") q := r.URL.Query()
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{ return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel), Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
SuppressTOC: true, SuppressTOC: true,
} }
} }
@@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string {
return fallback 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 { func rangeMonths(v string) int {
for _, r := range fitnessRanges { for _, r := range fitnessRanges {
if r.Value == v { if r.Value == v {
@@ -81,95 +88,6 @@ func rangeMonths(v string) int {
return 0 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 === // === Series extraction ===
type weightPoint struct { type weightPoint struct {
@@ -178,26 +96,34 @@ type weightPoint struct {
days int // >0: weekly mean over this many measured days days int // >0: weekly mean over this many measured days
} }
// extractWeights computes the weight series from the export: one point per // fitnessWeights reads the whole weight calendar up to today: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last // day, sorted by date. Dates are UTC midnights to match the chart's today.
// one wins. func fitnessWeights(today time.Time) []weightPoint {
func extractWeights(ex *wlExport) []weightPoint { byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
byDate := map[time.Time]float64{} weights := make([]weightPoint, 0, len(byDay))
for _, e := range ex.Diary { for key, w := range byDay {
day, ok := exportDate(e.DateTime) d, err := time.Parse("2006-01-02", key)
if !ok || !e.Stats.Weight.ok { v, err2 := strconv.ParseFloat(w, 64)
if err != nil || err2 != nil {
continue 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}) weights = append(weights, weightPoint{date: d, value: v})
} }
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights 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 { func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
} }
@@ -280,8 +206,9 @@ type fitnessTickVM struct {
Pos, Label, Anchor string Pos, Label, Anchor string
} }
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
type fitnessDotVM struct { type fitnessDotVM struct {
X, Y, Title string X, Y, Date, Value string
} }
type fitnessGoalVM struct { type fitnessGoalVM struct {
@@ -289,10 +216,11 @@ type fitnessGoalVM struct {
} }
type fitnessChartVM struct { type fitnessChartVM struct {
Title string Title string
Param string Param string
Options []fitnessOptVM Options []fitnessOptVM
Empty bool Absolute bool
Empty bool
ViewW, ViewH string ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string PlotX, PlotY, PlotR, PlotB string
@@ -301,6 +229,7 @@ type fitnessChartVM struct {
Goal *fitnessGoalVM Goal *fitnessGoalVM
Lines []string // polyline points attributes Lines []string // polyline points attributes
Dots []fitnessDotVM Dots []fitnessDotVM
Dense bool // too many points to draw markers
} }
type fitnessDashVM struct { type fitnessDashVM struct {
@@ -308,13 +237,13 @@ type fitnessDashVM struct {
Charts []fitnessChartVM Charts []fitnessChartVM
} }
func newChartVM(title, param, sel string) fitnessChartVM { func newChartVM(title, param string, view fitnessView) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges)) opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range 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{ return fitnessChartVM{
Title: title, Param: param, Options: opts, Title: title, Param: param, Options: opts, Absolute: view.Absolute,
ViewW: svgNum(chartW), ViewH: svgNum(chartH), ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop), PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom), 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 // 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 { func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step { 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 // 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 // series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement. // 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 { func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel) vm := newChartVM(title, param, view)
points := all points := all
var x0, x1 time.Time var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 { if m := rangeMonths(view.Range); m > 0 {
x0 = today.AddDate(0, -m, 0) x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0) points = filterWeights(all, x0)
if len(points) == 0 { if len(points) == 0 {
@@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
pad = 1 pad = 1
} }
sc := chartScale{x0, x1, lo - pad, hi + pad} 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.YTicks = yTickVMs(sc, yStep)
vm.XTicks = timeXTicks(sc, 4) vm.XTicks = timeXTicks(sc)
// One continuous polyline through every point in range — days without a // One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover // measurement do not break the line. Every point also gets a marker that
// <title>; on dense ranges they are dropped and the bare line stays // feeds the hover tooltip; on dense ranges the markers are hidden and the
// legible. A single point in range renders as a dot. // bare line stays legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM { dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit d := fitnessDotVM{
if p.days > 0 { X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
label = fmt.Sprintf("Week of %s: %s %s (%d days)", Date: p.date.Format("Mon 2006-01-02"),
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days) 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 { if len(points) >= 2 {
var b strings.Builder 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()) vm.Lines = append(vm.Lines, b.String())
} }
if len(points) <= 100 { for _, p := range points {
for _, p := range points { vm.Dots = append(vm.Dots, dot(p))
vm.Dots = append(vm.Dots, dot(p))
}
} }
vm.Dense = len(points) > 100
if hasGoal { if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal)) vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -441,34 +379,101 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
return out return out
} }
// timeXTicks places n+1 evenly spaced date labels across a continuous time // xTickStep is a calendar-aligned spacing for date labels.
// axis; the last label is end-anchored so it stays inside the viewBox. type xTickStep struct {
func timeXTicks(sc chartScale, n int) []fitnessTickVM { days, months int
span := sc.x1.Sub(sc.x0) label func(t time.Time, first bool) string
var out []fitnessTickVM }
prev := ""
for i := 0; i <= n; i++ { var xTickSteps = []xTickStep{
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n))) {days: 1, label: dayLabel},
label := t.Format("2006-01-02") {days: 7, label: dayLabel},
if label == prev { {days: 14, label: dayLabel},
continue {months: 1, label: monthLabel},
{months: 3, label: monthLabel},
{months: 6, label: monthLabel},
{months: 12, label: yearLabel},
{months: 24, label: yearLabel},
{months: 60, label: yearLabel},
}
func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") }
// monthLabel names the year only where it changes, so a year-long axis
// reads "Oct 2025, Jan 2026, Apr, Jul".
func monthLabel(t time.Time, first bool) string {
if first || t.Month() == time.January {
return t.Format("Jan 2006")
}
return t.Format("Jan")
}
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
// maxXTicks bounds the label count so labels never collide on the axis.
const maxXTicks = 5
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
// month starts or new years, whichever is the finest step that stays within
// maxXTicks. Labels near the plot edges are anchored inward so they stay
// inside the viewBox.
func timeXTicks(sc chartScale) []fitnessTickVM {
var ticks []time.Time
var step xTickStep
for _, step = range xTickSteps {
ticks = alignedTicks(sc.x0, sc.x1, step)
if len(ticks) <= maxXTicks {
break
} }
prev = label }
out := make([]fitnessTickVM, 0, len(ticks))
for i, t := range ticks {
x := sc.x(t)
anchor := "middle" anchor := "middle"
if i == n { switch {
case x-chartLeft < 24:
anchor = "start"
case chartW-chartRight-x < 24:
anchor = "end" 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 return out
} }
// === Rendering === // === 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 { func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now()) data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
var buf bytes.Buffer var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil { if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err) log.Printf("fitness template: %v", err)
@@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
return template.HTML(buf.String()) return template.HTML(buf.String())
} }
// buildFitnessDash reads and parses the export per request — no caching, no // buildFitnessDash reads the weight calendar per request (the calendar store
// indexes. A read or parse failure (including a truncated mid-upload file) // caches parsed files). A missing calendar setup becomes an inline notice;
// becomes an inline notice; the page itself always renders. // the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM { func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile)) if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
if err != nil { return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
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()}
} }
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex) weights := fitnessWeights(today)
wGoal, hasWGoal := goalValue(ex.Settings, "weight") wGoal, hasWGoal := weightGoal(fsPath)
unit := exportWeightUnit(ex.Settings) const unit = "kg"
return fitnessDashVM{Charts: []fitnessChartVM{ return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel, buildWeightChart(weights, wGoal, hasWGoal, weightView,
"weight", "Weight ("+unit+")", unit, today), "weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel, buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
"weekly", "Weekly average weight ("+unit+")", unit, today), "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 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 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+107 -53
View File
@@ -2,12 +2,9 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"flag" "flag"
"html/template" "html/template"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@@ -17,31 +14,20 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
) )
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle, // tmplFuncs is shared by every layout-based template: asset links versioned
// appended as ?v=… to its <script> src. The bundle is served immutable under a // static files (see static.go); the layout header renders the maintenance
// stable filename, so without this query a rebuilt bundle would never reach a // widget.
// 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.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion }, "asset": assetURL,
"fileIcon": fileIcon, "fileIcon": fileIcon,
"icon": readIcon,
"maintenanceWidget": maintenanceWidget,
} }
var ( var (
@@ -88,8 +74,18 @@ func main() {
user := flag.String("user", "", "basic auth username (empty = no auth)") user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password") pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)") 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() 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) root, err := filepath.Abs(*wikiDir)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -108,20 +104,22 @@ func main() {
initMarkdown() 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) authKey, err := loadOrCreateAuthKey(root)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey} h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
staticFS, _ := fs.Sub(assets, "assets") http.Handle("/_/", serveStatic())
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.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
@@ -158,6 +156,10 @@ func main() {
}(*reindexInterval) }(*reindexInterval)
} }
if calendarsDir != "" {
startCalendarSync()
}
log.Printf("datascape listening on %s, wiki at %s", *addr, root) log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil)) log.Fatal(http.ListenAndServe(*addr, nil))
} }
@@ -173,14 +175,21 @@ type reqStartKeyT struct{}
var reqStartKey = reqStartKeyT{} var reqStartKey = reqStartKeyT{}
// elapsedMS returns the milliseconds since the request entered ServeHTTP. // requestStart returns when the request entered ServeHTTP, or now if unknown.
func elapsedMS(r *http.Request) int64 { func requestStart(r *http.Request) time.Time {
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok { 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) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now())) 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 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+"/") { if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r) h.handleThumb(w, r)
return 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 // slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve // string intact — the canonical page form that relative destinations resolve
// against. // against.
@@ -273,6 +301,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
h.handlePost(w, r, urlPath, fsPath) h.handlePost(w, r, urlPath, fsPath)
return return
} }
if r.URL.Query().Has("dialog") {
h.serveDialog(w, r, urlPath, fsPath)
return
}
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
@@ -374,20 +406,21 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
Content: rendered, Content: rendered,
Entries: entries, Entries: entries,
View: view, View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC, SuppressTOC: suppressTOC,
} }
if !editMode {
data.Tree = sidebarTree(h.root, urlPath)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
t := pageTmpl t := pageTmpl
if editMode { if editMode {
t = editTmpl t = editTmpl
} }
data.RenderMS = elapsedMS(r) data.renderTimer = renderTimer{requestStart(r)}
if err := t.ExecuteTemplate(w, "layout", data); err != nil { if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
} }
} }
@@ -399,7 +432,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge")) // The destination comes either whole (?move=/dst, or the merge
// dialog's move field) or as the move dialog's parent + name.
// Body fields take precedence over the bare ?move query key.
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
dst := r.FormValue("move")
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
}
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
@@ -414,6 +458,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath) h.handleSettings(w, r, urlPath, fsPath)
return return
} }
if query.Has("calsync") {
h.handleCalendarSync(w, r, urlPath, fsPath)
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@@ -525,12 +573,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
// Returns nil if the file does not exist. // Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments. // Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings { 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")) data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil { if err != nil {
return nil return nil
} }
// Defaults; overridden only by valid values present in the file. kv := map[string]string{}
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -540,17 +604,7 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
value := strings.TrimSpace(parts[1]) kv[strings.TrimSpace(parts[0])] = 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)
}
} }
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
}
+9 -7
View File
@@ -14,8 +14,7 @@ import (
// The one exception is a merge: when the source carries a page (index.md) and // The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty // the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge // container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the // is true; otherwise the client is asked to confirm it.
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) { func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
@@ -51,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return return
} }
if !merge { if !merge {
// Ask the client to confirm the merge. The header lets it tell // Ask the user to confirm: htmx clients get the merge dialog as
// this apart from an ordinary conflict. // the next step; others a conflict they can retry with &merge=1.
w.Header().Set("X-Merge-Available", "1") if isHX(r) {
renderDialog(w, "merge", struct{ Dest string }{newPath})
return
}
http.Error(w, "destination already exists — merge folders?", http.StatusConflict) http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return return
} }
@@ -94,7 +96,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} }
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther) redirectAfter(w, r, pageURL(newPath))
} }
// canMergeMove reports whether moving onto an existing destination should // canMergeMove reports whether moving onto an existing destination should
@@ -169,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
return return
} }
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/")) folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther) redirectAfter(w, r, pageURL(parentURL(urlPath)))
} }
// normalizeMovePath returns the absolute path with any trailing slash removed, // normalizeMovePath returns the absolute path with any trailing slash removed,
+1 -1
View File
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, urlPath, http.StatusSeeOther) redirectAfter(w, r, pageURL(urlPath))
} }
// writePageSettings performs a read-modify-write of <dir>/.page-settings, // writePageSettings performs a read-modify-write of <dir>/.page-settings,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"time" "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 { type quickAddData struct {
To, URL, Title string To, URL, Title string
+2 -3
View File
@@ -58,12 +58,11 @@ type pageData struct {
Content template.HTML Content template.HTML
Entries []entry Entries []entry
View string // listing view style: "list" or "thumbnail" View string // listing view style: "list" or "thumbnail"
Sort string // listing sort key: "name" / "modified" / "size"
Order string // listing sort order: "asc" / "desc"
SpecialContent template.HTML SpecialContent template.HTML
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool SuppressTOC bool
RenderMS int64 Tree template.HTML // sidebar folder tree; empty in edit mode
renderTimer
} }
// Allowed values for the listing view settings. Unknown values in the file or // Allowed values for the listing view settings. Unknown values in the file or
+111 -13
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"html/template"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@@ -12,6 +13,7 @@ import (
"sync" "sync"
"time" "time"
"unicode" "unicode"
"unicode/utf8"
) )
// searchSectionCap bounds how many rows each results section renders. The // searchSectionCap bounds how many rows each results section renders. The
@@ -46,7 +48,8 @@ type searchPageData struct {
PageTotal int PageTotal int
FileTotal int FileTotal int
IndexBuiltAt time.Time IndexBuiltAt time.Time
RenderMS int64 Tree template.HTML // sidebar folder tree
renderTimer
} }
// indexEntry is the shared scoreable core of both folder and file index // indexEntry is the shared scoreable core of both folder and file index
@@ -130,10 +133,11 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
PageTotal: len(pages), PageTotal: len(pages),
FileTotal: len(files), FileTotal: len(files),
IndexBuiltAt: builtAt, IndexBuiltAt: builtAt,
Tree: sidebarTree(h.root, "/"),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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 { if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
log.Printf("search template error: %v", err) log.Printf("search template error: %v", err)
} }
} }
@@ -278,7 +282,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 { if best < 20 {
best = 20 best = 20
} }
case fuzzy && levenshtein(w, qt) <= 2: case fuzzy && withinTwoEdits(w, qt):
if best < 5 { if best < 5 {
best = 5 best = 5
} }
@@ -289,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
return score return score
} }
// handleSearchSuggest serves the JSON typeahead for the header dropdown and type suggestResult struct {
// the editor's link picker. Caps results at 5; reports total so the UI can Name string `json:"name"`
// surface a "show all" footer when more matches exist. Empty/whitespace query Path string `json:"path"`
// is a no-op (200 with empty results), not a 400 — every keystroke fires this. URL string `json:"url"`
}
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
// (header search, editor link dialog; htmx requests) and JSON for the
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
// can offer a "show all" footer (?footer) when more matches exist. An
// empty/whitespace query is a no-op (200, no results), not a 400 — every
// keystroke fires this.
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) { if !h.checkAuth(w, r) {
return return
} }
query := strings.TrimSpace(r.URL.Query().Get("q")) query := strings.TrimSpace(r.URL.Query().Get("q"))
type suggestResult struct {
Name string `json:"name"`
Path string `json:"path"`
URL string `json:"url"`
}
type suggestResp struct { type suggestResp struct {
Query string `json:"query"` Query string `json:"query"`
Results []suggestResult `json:"results"` Results []suggestResult `json:"results"`
@@ -324,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
}) })
} }
} }
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
// editor's [[ autocomplete) keep the JSON.
w.Header().Set("Vary", "HX-Request")
if isHX(r) {
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("search suggest encode error: %v", err) log.Printf("search suggest encode error: %v", err)
} }
} }
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
// suggestMinLen is the shortest query (in characters) the typeahead answers;
// shorter ones render nothing, which closes the dropdown.
const suggestMinLen = 2
type suggestRow struct {
Name, Path, URL string
Highlighted template.HTML
}
// renderSuggest writes the typeahead dropdown for query. footer adds a
// "Show all" row linking to the full results page when some were cut off.
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
data := struct {
Show bool
Results []suggestRow
Total int
AllURL string
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
tokens := suggestTokens(query)
for _, res := range results {
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
}
if footer && total > len(results) {
data.AllURL = "/?q=" + url.QueryEscape(query)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
log.Printf("suggest template error: %v", err)
}
}
// suggestTokens lowercases query and splits it on anything that is not a
// letter or digit.
func suggestTokens(query string) []string {
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
}
// highlightMatches bolds the first occurrence of each token in name
// (case-insensitive), merging overlapping spans, and escapes the rest.
func highlightMatches(name string, tokens []string) template.HTML {
lower := strings.ToLower(name)
if len(lower) != len(name) {
// Lowercasing changed byte lengths, so offsets would not line up.
return template.HTML(template.HTMLEscapeString(name))
}
type span struct{ from, to int }
var spans []span
for _, t := range tokens {
if i := strings.Index(lower, t); i >= 0 {
spans = append(spans, span{i, i + len(t)})
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
var b strings.Builder
cursor := 0
for i := 0; i < len(spans); i++ {
sp := spans[i]
for i+1 < len(spans) && spans[i+1].from <= sp.to {
i++
sp.to = max(sp.to, spans[i].to)
}
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
cursor = sp.to
}
b.WriteString(template.HTMLEscapeString(name[cursor:]))
return template.HTML(b.String())
}
// handleReindex rebuilds the folder index synchronously and returns 204. // handleReindex rebuilds the folder index synchronously and returns 204.
// The frontend reloads the page on success. Serialized via buildMu so a // The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel. // double-click waits rather than running two walks in parallel.
@@ -575,6 +662,17 @@ func tokenize(s string) []string {
return tokens 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 // levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit. // multi-byte characters count as one edit.
func levenshtein(a, b string) int { 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")
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import "testing"
func TestHighlightMatches(t *testing.T) {
cases := []struct{ name, query, want string }{
{"Project Alpha", "alpha", "Project <strong>Alpha</strong>"},
// Overlapping and adjacent spans merge.
{"Project Alpha", "pro ject", "<strong>Project</strong> Alpha"},
{"Project Alpha", "roj proj", "<strong>Proj</strong>ect Alpha"},
{"a<b>&c", "b", "a&lt;<strong>b</strong>&gt;&amp;c"},
{"Notes", "zzz", "Notes"},
// Lowercasing changes the byte length: no highlight, still escaped.
{"İstanbul <x>", "stan", "İstanbul &lt;x&gt;"},
}
for _, c := range cases {
if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want {
t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want)
}
}
}
+7 -6
View File
@@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
// then a `[ ]` / `[x]` / `[X]` checkbox marker. // then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`) var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered // rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching // their source line. The disabled attribute is removed and each checkbox
// its position among task list items in source order; the disabled attribute // posts itself to ?toggle=N (htmx), N being its position among task list
// is removed so the user can toggle them. // items in source order. An unchecked box sends no value, i.e. checked=false;
// a failed save flips the box back.
func rewriteTaskCheckboxes(in []byte) []byte { func rewriteTaskCheckboxes(in []byte) []byte {
idx := 0 idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte { return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked")) checked := bytes.Contains(match, []byte("checked"))
var out bytes.Buffer var out bytes.Buffer
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`) out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`)
out.WriteString(strconv.Itoa(idx)) out.WriteString(strconv.Itoa(idx))
out.WriteByte('"') out.WriteString(`" hx-swap="none" hx-disabled-elt="this" hx-on::response-error="this.checked = !this.checked" hx-on::send-error="this.checked = !this.checked"`)
if checked { if checked {
out.WriteString(` checked=""`) out.WriteString(` checked=""`)
} }
+41 -4
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -48,8 +49,13 @@ var (
thumbLocks = map[string]*sync.Mutex{} thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{} digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex 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 // 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 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) log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError) http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return 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, // 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 // 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. // 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) { func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock() digestCacheMu.Lock()
d, ok := digestCache[srcFS] d, ok := digestCache[srcFS]
@@ -207,18 +219,43 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
return d.hex, nil, nil 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) data, err := os.ReadFile(srcFS)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:]) 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() digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h} digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock() digestCacheMu.Unlock()
return h, data, nil
} }
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) { func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
+43 -16
View File
@@ -57,6 +57,14 @@ func resizeBox(src image.Image, width int) image.Image {
} }
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) 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++ { for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH sy1 := (y + 1) * srcH / dstH
@@ -69,23 +77,42 @@ func resizeBox(src image.Image, width int) image.Image {
if sx1 == sx0 { if sx1 == sx0 {
sx1 = sx0 + 1 sx1 = sx0 + 1
} }
var r, g, bl, n uint64 box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min)
for sy := sy0; sy < sy1; sy++ { dst.SetRGBA(x, y, avg(src, box))
for sx := sx0; sx < sx1; sx++ {
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
} }
} }
return dst return dst
} }
// boxAvg returns the mean colour of the pixels of src inside r.
func boxAvg(src image.Image, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
sr, sg, sb, _ := src.At(x, y).RGBA()
red += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly.
// Each pixel is still converted to RGB before averaging so out-of-gamut
// samples clamp exactly as they do through At().
func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
ci := src.COffset(x, y)
sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci])
red += uint64(sr)
g += uint64(sg)
bl += uint64(sb)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
)
// testPhoto returns a JPEG-decoded (so *image.YCbCr, 4:2:0) image with an odd
// size and a non-zero origin to exercise the plane offset arithmetic.
func testPhoto(t testing.TB, w, h int) *image.YCbCr {
t.Helper()
src := image.NewRGBA(image.Rect(0, 0, w, h))
for i := range src.Pix {
src.Pix[i] = uint8(i*7 + i/w)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
img, err := jpeg.Decode(&buf)
if err != nil {
t.Fatal(err)
}
return img.(*image.YCbCr).SubImage(image.Rect(3, 5, w, h)).(*image.YCbCr)
}
func TestResizeBoxYCbCrMatchesGeneric(t *testing.T) {
src := testPhoto(t, 1001, 667)
fast := resizeBox(src, 120).(*image.RGBA)
// Hide the concrete type so resizeBox takes the generic At() path.
slow := resizeBox(struct{ image.Image }{src}, 120).(*image.RGBA)
if fast.Bounds() != slow.Bounds() {
t.Fatalf("bounds %v != %v", fast.Bounds(), slow.Bounds())
}
for i := range fast.Pix {
if fast.Pix[i] != slow.Pix[i] {
t.Fatalf("pixel byte %d: fast %d, generic %d", i, fast.Pix[i], slow.Pix[i])
}
}
}
func BenchmarkResizeBox(b *testing.B) {
src := testPhoto(b, 4000, 3000)
b.Run("ycbcr", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(src, 300)
}
})
b.Run("generic", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(struct{ image.Image }{src}, 300)
}
})
}
// After a restart (empty memory cache) the digest comes from the on-disk index
// without reading the source file.
func TestSourceDigestPersists(t *testing.T) {
old := thumbCacheDir
thumbCacheDir = t.TempDir()
t.Cleanup(func() { thumbCacheDir = old })
src := filepath.Join(t.TempDir(), "a.jpg")
if err := os.WriteFile(src, []byte("photo"), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(src)
h1, data, err := sourceDigest(src, info)
if err != nil || data == nil {
t.Fatalf("first call: %v, data=%v", err, data)
}
digestCacheMu.Lock()
delete(digestCache, src)
digestCacheMu.Unlock()
// Removing the source proves the second call does not read it again.
if err := os.Remove(src); err != nil {
t.Fatal(err)
}
h2, data, err := sourceDigest(src, info)
if err != nil || data != nil || h2 != h1 {
t.Fatalf("after restart: h=%s data=%v err=%v, want %s from index", h2, data, err, h1)
}
}
+168 -53
View File
@@ -1,30 +1,71 @@
package main package main
import ( import (
"encoding/json" "bytes"
"html/template"
"log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
) )
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
// treeOpts selects what a folder tree renders. The same markup serves the
// sidebar (links) and the tree picker (radio inputs named "pick").
type treeOpts struct {
Files bool // list files, not just folders
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
Active string // wiki path of the current page, highlighted (sidebar)
Select string // wiki path of the initially checked row (picker)
}
// query returns the ?tree query that lazy-loads a folder's children with the
// same options.
func (o treeOpts) query() string {
q := "?tree"
if o.Files {
q += "&files=1"
}
if o.Pick != "" {
q += "&pick=" + o.Pick
}
return q
}
type treeEntry struct { type treeEntry struct {
Name string `json:"name"` Name string
Kind string `json:"kind"` Kind string // "folder" or "file"
// Children is populated only along the expandTo chain (see handleTree); // HasChildren reports whether a folder has any visible entries (under the
// omitted otherwise so the flat picker listing keeps its original shape. // same files filter), so empty folders render without a chevron.
Children []treeEntry `json:"children,omitempty"` HasChildren bool
} }
type treeResponse struct { // treeRow is one rendered row. Folders with children are expandable: either
Path string `json:"path"` // pre-expanded (Children set, along the requested path) or lazy-loaded from
Entries []treeEntry `json:"entries"` // ChildrenURL when first opened.
type treeRow struct {
Name, Path, Kind string
URL string // page URL (sidebar links)
Link bool // render the name as a link
Selectable bool // render a picker radio
Active, Checked bool
Expandable bool
Children []treeRow
ChildrenURL string
} }
// handleTree responds with a JSON listing of the immediate children of the // handleTree responds with the rendered rows of the folder at fsPath:
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not //
// descended — the client lazy-loads children on expand. // ?tree sidebar rows (folders only, links)
// &files=1 include files
// &pick=folder|file|any picker rows selecting that kind
// &expandTo=/a/b pre-expand the chain down to /a/b
// &select=/a/b check that row
// &root=1 lead with a row for "/" itself (picker)
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
@@ -40,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
foldersOnly := r.URL.Query().Has("foldersOnly") q := r.URL.Query()
o := treeOpts{Files: q.Has("files"), Select: q.Get("select")}
switch p := q.Get("pick"); p {
case "", "folder", "file", "any":
o.Pick = p
default:
http.Error(w, "bad pick mode", http.StatusBadRequest)
return
}
entries, err := listTreeEntries(fsPath, foldersOnly) dir := normalizeMovePath(urlPath)
var expand []string
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
expand = segs[len(base):]
}
rows, err := buildTreeRows(fsPath, dir, expand, o)
if err != nil { if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError) http.Error(w, "read failed", http.StatusInternalServerError)
return return
} }
if dir == "/" && q.Has("root") && o.Pick != "" {
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" { root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly) rows = append([]treeRow{root}, rows...)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil {
log.Printf("tree template error: %v", err)
} }
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
} }
// canonicalTreePath returns the URL path in the form used by the picker: // sidebarTree renders the navigation tree for the page at urlPath: the root
// "/" for root, otherwise stripped of any trailing slash. // listing with the chain down to the page expanded and the page highlighted.
func canonicalTreePath(urlPath string) string { // The tree is part of the page itself, so the rail never paints empty.
if urlPath == "" || urlPath == "/" { func sidebarTree(root, urlPath string) template.HTML {
return "/" page := normalizeMovePath(urlPath)
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
if err != nil {
return ""
} }
return "/" + strings.Trim(urlPath, "/") var buf bytes.Buffer
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
log.Printf("tree template error: %v", err)
return ""
}
return template.HTML(buf.String())
}
// buildTreeRows lists the folder fsPath (wiki path dir) and expands the
// folder named by expand[0], recursing down the chain. The walk stops at the
// deepest segment that still exists. Segments only ever match real directory
// names from listTreeEntries, so this cannot traverse outside the tree.
func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
entries, err := listTreeEntries(fsPath, !o.Files)
if err != nil {
return nil, err
}
rows := make([]treeRow, len(entries))
for i, e := range entries {
p := path.Join(dir, e.Name)
rows[i] = newTreeRow(e, p, o)
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
if err == nil {
rows[i].Children = kids
}
}
}
return rows, nil
}
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
row := treeRow{
Name: e.Name,
Path: p,
Kind: e.Kind,
URL: pageURL(p),
Link: o.Pick == "",
Active: o.Pick == "" && p == o.Active,
Expandable: e.HasChildren,
}
if e.Kind == "file" {
row.URL = fileURL(strings.TrimPrefix(p, "/"))
}
if o.Pick != "" {
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
row.Checked = row.Selectable && p == o.Select
}
if row.Expandable {
row.ChildrenURL = row.URL + o.query()
}
return row
} }
// listTreeEntries returns the immediate children of fsPath, filtering hidden // listTreeEntries returns the immediate children of fsPath, filtering hidden
@@ -81,7 +190,11 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
continue continue
} }
if e.IsDir() { 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 { } else {
if foldersOnly || name == "index.md" { if foldersOnly || name == "index.md" {
continue continue
@@ -94,6 +207,33 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
return append(folders, files...), nil 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. // treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string { func treePathSegments(p string) []string {
var segs []string var segs []string
@@ -104,28 +244,3 @@ func treePathSegments(p string) []string {
} }
return segs return segs
} }
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestHandleTreeExpandTo(t *testing.T) {
root := t.TempDir()
for _, d := range []string{"Projects/A/Deep", "Other/B"} {
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0755); err != nil {
t.Fatal(err)
}
}
h := &handler{root: root}
get := func(urlPath, query string) string {
r := httptest.NewRequest("GET", urlPath+"?"+query, nil)
w := httptest.NewRecorder()
h.handleTree(w, r, urlPath, filepath.Join(root, filepath.FromSlash(strings.Trim(urlPath, "/"))))
return w.Body.String()
}
// A chain below the listed folder is pre-expanded.
if body := get("/Projects/", "tree&expandTo=/Projects/A"); !strings.Contains(body, `href="/Projects/A/Deep/"`) {
t.Errorf("expandTo below the folder did not expand:\n%s", body)
}
// Lazy children keep the picker options.
if body := get("/", "tree&pick=folder&root=1"); !strings.Contains(body, `hx-get="/Projects/?tree&amp;pick=folder"`) ||
!strings.Contains(body, `value="/"`) {
t.Errorf("picker rows missing lazy URL or root row:\n%s", body)
}
w := httptest.NewRecorder()
h.handleTree(w, httptest.NewRequest("GET", "/?tree&pick=bogus", nil), "/", root)
if w.Code != 400 {
t.Errorf("bad pick mode: got %d, want 400", w.Code)
}
}