Compare commits

...
27 Commits
Author SHA1 Message Date
luxick c456e35c76 Fix section linking and editing 2026-10-04 16:01:47 +02:00
luxick 06da56d0a0 Canvas V2 2026-10-04 15:58:27 +02:00
luxick 95002a34ad Minor canvas improvements 2026-10-04 15:34:47 +02:00
luxick 2903986f44 Canvas support V1 2026-10-04 15:28:06 +02:00
luxick 7749d7c237 Use HTMX fragements on the fitness dashboard 2026-10-02 17:42:29 +02:00
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
luxick 7b451bae86 Hide companion features on mobile 2026-09-22 21:24:29 +02:00
luxick 1925987459 Remove agent files 2026-09-22 21:24:15 +02:00
luxick b57e5d454b Rework wikilinks feature 2026-09-17 17:46:36 +02:00
89 changed files with 8865 additions and 2557 deletions
+3
View File
@@ -11,3 +11,6 @@ companion/datascape-companion-*
# Editor build tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/
AGENTS.md
CLAUDE.md
-7
View File
@@ -1,7 +0,0 @@
# CLAUDE.md
I want to understand every line of code that goes into this project. Never create, edit, move, rename, or delete project files unless I explicitly ask you to do so. Instead, show me every proposed edit in the chat so I can type it in manually.
Do not run commands that modify project files, install dependencies, or change repository state unless I explicitly request that action. Instead, show me those commands in the chat so I can run them manually.
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
+13 -4
View File
@@ -4,20 +4,24 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
# Release builds drop the symbol table, DWARF debug info and local source
# paths. Panics still print file:line stack traces; only debuggers lose out.
RELEASE_FLAGS := -trimpath -ldflags="-s -w"
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
.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
# before `go build .` so embed.FS picks them up.
companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
$(COMPANION_WIN): $(COMPANION_SRCS)
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H windowsgui" -o $@ ./cmd/companion
$(COMPANION_LIN): $(COMPANION_SRCS)
GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -o $@ ./cmd/companion
companion-windows: $(COMPANION_WIN)
companion-linux: $(COMPANION_LIN)
@@ -37,8 +41,13 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS)
cd editor-build && npm ci && npm run build
deploy: companion-release
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
GOOS=linux GOARCH=arm GOARM=7 go build $(RELEASE_FLAGS) -o datascape-arm .
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
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
+107 -75
View File
@@ -4,117 +4,149 @@ Minimal self-hosted personal wiki. Folders are pages.
## Features
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
- **Pages**: a folder's `index.md` renders as HTML, and the folder's other files are listed below it. Going to a missing path lets you create the page there.
- **Editing**: edit the whole page or a single heading's section. Tick task checkboxes directly on the rendered page.
- **Move, rename, delete**: moving a page onto an empty folder offers to merge into it.
- **View settings**: show the file list as a list or thumbnail grid, sorted by name, date, or size. Videos get thumbnails when `ffmpeg` is installed.
- **Search**: find pages by name.
- **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with `![caption](photo.jpg "left")`.
- **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser.
- **Canvases**: [JSON Canvas](https://jsoncanvas.org) boards stored next to a page. See [Canvas](#canvas).
- **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness).
- **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page.
- **Quick-add bookmarklet**: save the current tab to a wiki page.
- **Companion app**: an optional desktop helper, downloadable from the wiki footer. It opens wiki files in local apps and folders in the file manager.
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
## Build & run
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
## Build
The wiki binary embeds the companion binaries, so build those first:
```bash
# local
go build -o datascape .
# QNAP NAS (linux/arm)
GOOS=linux GOARCH=arm go build -o datascape .
make companion-release # once, and after companion changes
go build -o datascape . # local
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
./datascape -dir ./wiki -addr :8080 -user me -pass secret
```
## Usage
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
| Flag | Default | Description |
|------|---------|-------------|
| Flag | Default | |
|------|---------|-|
| `-addr` | `:8080` | Listen address |
| `-dir` | `./wiki` | Wiki root directory |
| `-cache` | `./cache` | Thumbnail cache directory |
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
| `-pass` | _(none)_ | Basic auth password |
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
| `-dir` | `./wiki` | Wiki root |
| `-cache` | `./cache` | Thumbnail cache |
| `-user`, `-pass` | | Basic auth (omit to disable) |
| `-reindex-interval` | `30m` | Search index rebuild (`0` = off) |
| `-calendars` | `Calendars` | [Calendars](#calendars) folder, relative to `-dir` (empty = off) |
| `-tz` | system | Time zone for calendars and the diary, e.g. `Europe/Berlin` |
## View Settings
## `.page-settings`
The **view** button in a folder's `Files` header sets how its listing renders,
persisting three keys to `.page-settings`:
Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys.
| Key | Values (default first) |
|------|------------------------|
|-----|------------------------|
| `view` | `list`, `thumbnail` |
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
| `sort` | `name`, `modified`, `size` |
| `order` | `asc`, `desc` |
| `type` | `diary`, `fitness`, `calendars` |
## Special Folder Types
## Keyboard shortcuts
A folder can opt into special rendering by adding a `.page-settings` file. The
same file also holds the [View Settings](#view-settings) keys; only the `type`
key selects a special renderer:
All use Alt+Shift.
| Where | Keys |
|-------|------|
| Anywhere | `E` edit (on a canvas: the selected node), `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 |
## Canvas
A page can hold any number of canvases: `<name>.canvas` files in its folder, in the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) format. **CANVAS** in the page actions creates one. Canvases show up in the page's file list. Opening one shows the whole canvas, with no folder tree or sidebar. `?raw` on its URL returns the file itself.
- **Notes** (text nodes) are markdown, rendered like pages: wikilinks, embeds, tables, task checkboxes. Double-click a note to edit it with the page editor, including its toolbar and shortcuts. Esc or a click outside closes the editor. A note left empty is removed.
- **Files** show images, videos and audio. A markdown file or a page (a folder) is shown rendered, and a `#Heading` subpath narrows it to that section. Anything else shows as a link. Paths are relative to the wiki root.
- **Links** show as a card. **Groups** are labelled frames: moving a group moves what is inside it.
- **Connections**: drag from a node's side handle onto another node. Dropping on empty space creates a new note there. Double-click a connection to label it.
| Input | |
|-------|-|
| Drag background / Space+drag / middle drag | Pan |
| Wheel, pinch | Zoom (Shift+wheel pans sideways) |
| Shift+drag background | Select an area |
| Double-click background | New note |
| Double-click node, Enter, Alt+Shift+E | Edit (label, URL or file for non-notes) |
| Drag node / corner handle | Move / resize, snapped to a 10 px grid (Alt: no snap) |
| Arrows | Nudge (Shift: 1 px) |
| Del, Ctrl+Z, Ctrl+Y, Ctrl+D, Ctrl+A | Delete, undo, redo, duplicate, select all |
Changes save automatically. If the file changes elsewhere while a canvas is open, saving stops and asks for a reload instead of overwriting.
## Diary
```
type = diary
Diary/
.page-settings ← type = diary
2026/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
2026-05-11 Beach.jpg ← photos go under their date's section
```
### Diary
The year page shows each day's section with its photos. Months and days that are missing (up to today) appear as placeholders with an `[edit]` button. A calendar in the sidebar jumps to any date.
Designed for a chronological photo diary. The whole year lives in a single
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
## Fitness
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
## Calendars
```
FolderName/
.page-settings ← type = diary
YYYY/
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
Calendars/
.page-settings ← type = calendars, roles, sync credentials
Personal.ics ← one file per calendar
Gewicht.ics
…
```
The year page (`YYYY/`) renders every section in the file with photos
attached to each `### YYYY-MM-DD` heading. Months and days the file doesn't
yet contain are rendered as **virtual** headings with an `[edit]` button that
splices a new section into the year file at the right chronological position;
virtual day headings still carry photos for that date. Past years render
every month/day slot; the current year stops at today; future years skip
virtual entries entirely. The file may contain non-date headings (e.g.
`## Events` → `### Festival` between `# YYYY` and `## YYYY-01`); these keep
their document position.
Pages only read these local files and never contact the calendar server. The built-in sync downloads all calendars of a CalDAV calendar home in the background. Or you can put the `.ics` files there yourself.
A sidebar calendar widget shows one month grid at a time; the month-name
button opens a dropdown of all twelve months, and a separate year dropdown
jumps between years. Day cells link to the matching anchor on the year page
regardless of whether the date has a real section yet.
```
type = calendars
#### Persistent date links
# Roles: file names without .ics
diary = Personal, Feiertage
weight = Gewicht
maintenance = Maintainance
Each diary root exposes three stable paths intended for browser bookmarks.
They resolve against the year page rather than separate per-day URLs:
# Sync (Nextcloud)
caldav-url = https://cloud.example/remote.php/dav/calendars/me/
caldav-user = me
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
sync-interval = 15m
```
| Path | Redirects to |
|------|-------------|
| `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) |
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
| `<diary>/this-year/` | `<diary>/YYYY/` |
| Key | |
|-----|-|
| `diary` | Events shown under each diary day: all-day first, then by start time |
| `weight` | Titles like `95,6kg`, shown under the diary day's events |
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
| `caldav-url` | Calendar home. Empty = no sync |
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder.
## Quick-Add Bookmarklet
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
## Quick-add bookmarklet
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page:
```javascript
javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})();
```
Each save appends an entry of the following form to the destination page's `index.md`:
Each save adds this to the target page's `index.md`:
```markdown
- [Example Page](https://example.com)
+45
View File
@@ -0,0 +1,45 @@
<h2 id="maintenance">Maintenance</h2>
{{if .Maintenance}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Task</th><th>Repeats</th><th>Last</th><th>Next</th></tr></thead>
<tbody>
{{range .Maintenance}}
<tr><td>{{.Summary}}</td><td class="muted">{{.Rule}}</td><td class="nowrap">{{.Last}}</td><td class="nowrap">{{.Next}}</td></tr>
{{end}}
</tbody>
</table></div>
{{else if .HasMaintenance}}
<p class="muted">No maintenance tasks within a year of today.</p>
{{else}}
<p class="muted">No maintenance calendar. Set <code>maintenance = &lt;name&gt;</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
<h2 id="calendars">Calendars</h2>
{{if .Calendars}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Calendar</th><th>Role</th><th>Events</th><th>Updated</th></tr></thead>
<tbody>
{{range .Calendars}}
<tr>
<td>{{.Name}}{{if .Error}}<div class="cal-error">{{.Error}}</div>{{end}}</td>
<td class="muted">{{.Roles}}</td>
<td>{{.Events}}</td>
<td class="nowrap">{{.Updated}}</td>
</tr>
{{end}}
</tbody>
</table></div>
{{else}}
<p class="muted">No <code>.ics</code> files in this folder yet.</p>
{{end}}
{{if .IsSyncDir}}
{{if .SyncEnabled}}
<form class="cal-sync row" method="post" action="?calsync">
<button type="submit" class="btn">Sync now</button>
<span class="muted">{{if .LastRun}}Last sync: {{.LastRun}}{{else}}Not synced yet{{end}}</span>
</form>
{{if .LastError}}<p class="cal-error">{{.LastError}}</p>{{end}}
{{else}}
<p class="muted">Sync is off. Set <code>caldav-url</code>, <code>caldav-user</code> and <code>caldav-pass</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
{{end}}
+16
View File
@@ -0,0 +1,16 @@
<span class="dropdown maint">
<button type="button" class="btn maint-toggle{{if not .Count}} is-idle{{end}}" data-action="maint-menu" aria-haspopup="true" aria-expanded="false" title="Maintenance (H)">{{icon "maintenance"}}<span class="maint-count">{{.Count}}</span></button>
<div class="dropdown-menu align-right maint-menu">
<div class="panel-header">Today</div>
<div class="maint-list">
{{range .Today}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing due</span>{{end}}
</div>
<div class="panel-header">Next 7 days</div>
<div class="maint-list">
{{range .Upcoming}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing planned</span>{{end}}
</div>
{{if .PageURL}}<a class="btn btn-block maint-all" href="{{.PageURL}}">All maintenance →</a>{{end}}
</div>
</span>
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{{/* The canvas view (canvas.go). It fills the whole shell: no folder tree,
no sidebar, no footer. Links are not boosted, so leaving the canvas is a full
navigation and canvas.js can save on pagehide. */}}
{{define "boost"}} hx-boost="false"{{end}}
{{define "page-actions"}}<div id="page-actions" class="page-actions">{{template "headerActions" .}}</div>{{end}}
{{define "headerActions"}}
<a class="btn btn-block" href="{{.PageURL}}" title="Back to the page"><span class="btn-icon">{{icon "up"}}</span><span class="btn-label">PAGE</span></a>
<a class="btn btn-block" href="{{.RawURL}}" download="{{.Name}}" title="Download the canvas file"><span class="btn-icon">{{icon "doc"}}</span><span class="btn-label">DOWNLOAD</span></a>
{{end}}
{{define "shell"}}
<div class="canvas-app" data-version="{{.Version}}"{{if .Error}} data-readonly{{end}}>
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}}
<div class="canvas-stage">
<div class="canvas-viewport" tabindex="0">
<div class="canvas-world">
<svg class="canvas-edges" aria-hidden="true"></svg>
<div class="canvas-edge-labels"></div>
<div class="canvas-nodes">{{range .Nodes}}{{template "canvas-node" .}}{{end}}</div>
</div>
<p class="canvas-hint"{{if .Nodes}} hidden{{end}}>Double-click to add a note. Right-click or long-press for more.</p>
</div>
<div class="canvas-statusbar muted"><span class="canvas-status" aria-live="polite"></span><span class="canvas-zoom"></span></div>
{{if not .Error}}<div class="canvas-editor-toolbar" hidden>{{template "editor-toolbar"}}</div>{{end}}
</div>
<script type="application/json" id="canvas-data">{{.Doc}}</script>
</div>
{{if not .Error}}
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="{{asset "editor/tables.js"}}"></script>
<script src="{{asset "editor/dates.js"}}"></script>
<script src="{{asset "editor/movie.js"}}"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="{{asset "editor/core.js"}}"></script>
{{end}}
<script src="{{asset "canvas/canvas.js"}}"></script>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{/* One canvas node element (canvas.go canvasNodeView). Rendered for the
whole canvas on load and for a single node by POST ?render, so the editor
(canvas/canvas.js) never builds node markup itself. Geometry is in canvas
units; canvas.js keeps style and data-color in sync while editing. */}}
{{define "canvas-node"}}<div class="canvas-node canvas-node-{{.Type}}" data-id="{{.ID}}"{{with .Preset}} data-color="{{.}}"{{end}} style="left: {{.X}}px; top: {{.Y}}px; width: {{.W}}px; height: {{.H}}px;{{with .Hex}} --node-color: {{.}};{{end}}">
{{- if .Label}}<div class="canvas-node-label truncate">{{if .LabelURL}}<a href="{{.LabelURL}}">{{.Label}}</a>{{else}}{{.Label}}{{end}}</div>{{end -}}
<div class="canvas-node-body{{if .Markdown}} content{{end}}">{{.Body}}</div></div>{{end}}
{{define "canvas-media"}}{{if eq .Kind "image"}}<a class="canvas-media" href="{{.URL}}"><img src="{{.Thumb}}" alt="" loading="lazy" draggable="false"></a>
{{- else if eq .Kind "video"}}<video class="canvas-media" src="{{.URL}}" controls preload="metadata"></video>
{{- else}}<audio class="canvas-media" src="{{.URL}}" controls preload="metadata"></audio>{{end}}{{end}}
{{define "canvas-file"}}<a class="canvas-card" href="{{.URL}}"><span class="canvas-card-icon">{{.Icon}}</span><span class="truncate">{{.Name}}</span></a>{{end}}
{{define "canvas-link"}}<a class="canvas-card" href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}"><img class="canvas-card-icon" src="https://icons.duckduckgo.com/ip3/{{.Host}}.ico" alt="" width="16" height="16" draggable="false"><span class="canvas-card-text">{{if .Label}}<span class="truncate">{{.Label}}</span><span class="muted truncate">{{.Host}}</span>{{else}}<span class="truncate">{{.Host}}</span><span class="muted truncate">{{.URL}}</span>{{end}}</span></a>{{end}}
{{define "canvas-missing"}}<p class="canvas-missing">Missing: {{.}}</p>{{end}}
{{define "canvas-group-bg"}}<div class="canvas-group-bg canvas-group-bg-{{.Style}}" style="background-image: url('{{.URL}}')"></div>{{end}}
+25 -4
View File
@@ -132,6 +132,8 @@
// Folders end with "/" — let the browser navigate normally.
var path = (item && item.dataset.path) || anchor.getAttribute('href');
if (!path || path.endsWith('/')) return;
// Canvases open in the wiki's own canvas view.
if (/\.canvas$/i.test(path.split(/[?#]/)[0])) return;
// Allow modified clicks (open in new tab, etc.) to pass through.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var rel = wikiPathFromHref(path);
@@ -186,15 +188,34 @@
});
}
function isMobileDevice() {
// The companion only ships for Windows and Linux desktops, so skip it
// on phones/tablets. Checks the UA and falls back to "touch is the only
// pointer" so narrow desktop windows still get the companion.
if (/Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent)) return true;
return window.matchMedia('(pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches;
}
function init() {
// Every companion element is rendered hidden, so bailing out here
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () {
updateFooterIcon();
wireFileLinks();
wireRevealButton();
wireFileRevealButtons();
wireFileLinks(); // delegated to document: once
applyToPage();
// Boosted navigation swaps in a new page without reloading this
// script: re-apply to its footer icon and reveal buttons.
document.addEventListener('page:swapped', applyToPage);
});
}
function applyToPage() {
updateFooterIcon();
wireRevealButton();
wireFileRevealButtons();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
+1 -1
View File
@@ -24,4 +24,4 @@
</table>
{{end}}
</div>
<script src="/_/diary/calendar.js"></script>
<script src="{{asset "diary/calendar.js"}}"></script>
+4
View File
@@ -10,4 +10,8 @@
{{end}}
</div>
{{end}}
{{if or .Events .Weight}}<div class="diary-day-cal muted">
{{if .Events}}<p>{{.Events}}</p>{{end}}
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
</div>{{end}}
{{end}}
+434
View File
@@ -0,0 +1,434 @@
// core.js — the markdown editor: the CodeMirror setup plus the formatting
// actions behind the toolbar (editor/toolbar.html) and the Alt+Shift
// shortcuts. Shared by the page editor (main.js) and the canvas node editor
// (canvas/canvas.js).
//
// MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy }
// opts.doc initial text
// opts.keymap extra CM key bindings, ahead of the defaults
// opts.onChange called after every change to the text
// MarkdownEditor.bind(root, getActions)
// wires root's [data-action] buttons, [data-key] shortcuts and toolbar
// dropdowns to whatever getActions() returns (null = no editor open).
window.MarkdownEditor = (function () {
var T = EditorTables;
var D = EditorDates;
var M = EditorMovie;
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
// pageBase is the folder of the page being edited, slash-terminated: the
// page URL itself, or for a canvas (/Page/Board.canvas) the folder it
// lives in. Relative embed destinations resolve against it.
function pageBase() {
var p = decodeURIComponent(window.location.pathname);
return p.slice(0, p.lastIndexOf('/') + 1);
}
function create(mount, opts) {
opts = opts || {};
var doc = opts.doc || '';
var view;
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
// highest precedence so they win over CM's default newline/forward-delete.
// Returning false (no table at cursor) lets CM fall back to its default.
function tableKey(fn) {
return function (v) {
var result = fn(v.state.doc.toString(), v.state.selection.main.head);
if (!result) return false;
dispatchFullReplace(result);
return true;
};
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (v) {
if (completionOpen()) return false;
return handler(v);
};
}
var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
{ key: 'Tab', run: tableNavKey(T.nextCell) },
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
];
var state = CM.EditorState.create({
doc: doc,
// Start with the caret at the end of the existing content — editing an
// existing page almost always means appending, and the default (start
// of doc) buries the caret behind the leading heading.
selection: { anchor: doc.length },
extensions: [
// The caller's keys go first, ahead of even autocompletion's
// own highest-precedence keys: those swallow an Escape while a
// completion query is still pending, with no popup in sight.
CM.Prec.highest(CM.keymap.of(opts.keymap || [])),
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
// Enable native browser spellcheck on the contenteditable surface
// (CM6 leaves it off by default). autocapitalize helps prose entry
// on the Android/mobile path; CM's DOM observer absorbs corrections.
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
CM.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(),
CM.autocompletion({ override: [WikiComplete.source] }),
CM.theme,
opts.onChange ? CM.EditorView.updateListener.of(function (u) {
if (u.docChanged) opts.onChange();
}) : [],
CM.Prec.highest(CM.keymap.of(tableKeymap)),
CM.keymap.of([].concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab]
)),
],
});
view = new CM.EditorView({ state: state, parent: mount });
view.focus();
// --- CM document helpers ---
function dispatchFullReplace(result) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.text },
selection: { anchor: result.cursor },
scrollIntoView: true,
});
view.focus();
}
function insertAtCursor(s) {
var sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: s },
selection: { anchor: sel.from + s.length },
scrollIntoView: true,
});
view.focus();
}
function wrap(before, after, placeholder) {
var sel = view.state.selection.main;
var hadSelection = sel.to > sel.from;
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
var insert = before + selected + after;
var anchor, head;
if (hadSelection) {
anchor = head = sel.from + insert.length;
} else {
anchor = sel.from + before.length;
head = anchor + placeholder.length;
}
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: insert },
selection: { anchor: anchor, head: head },
scrollIntoView: true,
});
view.focus();
}
function linePrefix(prefix) {
var sel = view.state.selection.main;
var line = view.state.doc.lineAt(sel.from);
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix },
selection: { anchor: sel.from + prefix.length },
scrollIntoView: true,
});
view.focus();
}
function applyTableOp(fn, arg) {
var text = view.state.doc.toString();
var pos = view.state.selection.main.head;
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
if (result) dispatchFullReplace(result);
}
// Adapter passed to movie.js so it reads/writes the CM document instead of a
// textarea (replace() dispatches a transaction; cursor lands after the block).
var movieCtx = {
getValue: function () { return view.state.doc.toString(); },
replace: function (start, end, text) {
view.dispatch({
changes: { from: start, to: end, insert: text },
selection: { anchor: start + text.length },
scrollIntoView: true,
});
view.focus();
},
};
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
var queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: !selectedText,
onConfirm: function () {
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!queryInput.value.trim());
}
queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(queryInput, {
showFooter: false,
container: queryWrap,
onPick: function (r) {
queryInput.value = r.name;
updateConfirm();
displayInput.focus();
}
});
}
// embedDest turns the absolute wiki path the tree picker hands back into the
// destination written to the document: relative to the page being edited
// when the file lives under it, absolute otherwise. Pages render at a
// slash-terminated URL, so a relative destination resolves against the page
// in the browser.
function embedDest(absPath) {
var base = pageBase();
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
}
// embedToken formats a Markdown image. A destination carrying spaces or
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
// piece — and the title slot holds the alignment keyword the renderer reads.
// Right is the renderer's default, so it is left off.
function embedToken(dest, caption, align) {
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
}
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
// caption and an alignment. Images render as a floated figure; anything
// else degrades to a link.
function insertEmbed() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var here = pageBase().replace(/\/+$/, '') || '/';
openTreePicker({
title: 'Embed — pick a file',
mode: 'file',
initialPath: here,
confirmLabel: 'NEXT',
onSelect: function (absPath) {
var container = document.createElement('div');
var captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'input';
captionInput.placeholder = 'Caption (optional)';
captionInput.value = selectedText;
var alignSelect = document.createElement('select');
alignSelect.className = 'input';
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
.forEach(function (opt) {
var o = document.createElement('option');
o.value = opt[0];
o.textContent = opt[1];
alignSelect.appendChild(o);
});
container.appendChild(captionInput);
container.appendChild(alignSelect);
var handle = openModal({
title: 'Embed — caption?',
body: container,
confirm: {
label: 'INSERT',
onConfirm: function () {
handle.close();
insertAtCursor(embedToken(
embedDest(absPath),
captionInput.value.trim(),
alignSelect.value
));
}
}
});
}
});
}
var actions = {
undo: function () { CM.undo(view); view.focus(); },
redo: function () { CM.redo(view); view.focus(); },
deleteline: function () { CM.deleteLine(view); view.focus(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(movieCtx); },
};
return {
view: view,
actions: actions,
getValue: function () { return view.state.doc.toString(); },
focus: function () { view.focus(); },
destroy: function () { view.destroy(); },
};
}
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// menu to the viewport so it escapes the clip, opening upward when there
// isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed';
menu.style.overflowY = 'auto';
var spaceBelow = vh - r.bottom;
var spaceAbove = r.top;
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
menu.style.top = 'auto';
menu.style.bottom = (vh - r.top) + 'px';
menu.style.maxHeight = (spaceAbove - 8) + 'px';
} else {
menu.style.bottom = 'auto';
menu.style.top = r.bottom + 'px';
menu.style.maxHeight = (spaceBelow - 8) + 'px';
}
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px';
}
function bind(root, getActions) {
function run(name) {
var actions = getActions();
var action = actions && actions[name];
if (action) action();
}
var keyMap = {};
root.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () { run(btn.dataset.action); });
if (btn.dataset.key) keyMap[btn.dataset.key] = btn.dataset.action;
});
// Keep the editor focused when a toolbar button is tapped. Without this the
// button steals focus on mousedown, which dismisses the mobile soft keyboard
// before the action runs (and view.focus() can't reopen it without a direct
// gesture). preventDefault on mousedown blocks the focus shift; click still
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
// dropdown toggles, which also must not pull focus off the editor.
var toolbar = root.classList.contains('editor-toolbar') ? root : root.querySelector('.editor-toolbar');
if (toolbar) {
toolbar.addEventListener('mousedown', function (e) {
if (e.target.closest('.btn')) e.preventDefault();
});
}
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey || !getActions()) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
// on US, "!" on DE), so fall back to e.code for digit rows.
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
var name = keyMap[key];
if (name) {
e.preventDefault();
run(name);
}
});
root.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
wireDropdown(toggle);
var menu = toggle.parentElement.querySelector('.dropdown-menu');
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
// Runs after wireDropdown's own click handler has toggled is-open.
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
});
}
return { create: create, bind: bind };
})();
+8 -69
View File
@@ -11,76 +11,15 @@
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
</div>
</span>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
</div>
</span>
</div>
{{template "editor-toolbar"}}
<div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/editor/main.js"></script>
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
<script src="{{asset "editor/tables.js"}}"></script>
<script src="{{asset "editor/dates.js"}}"></script>
<script src="{{asset "editor/movie.js"}}"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script>
<script src="{{asset "editor/core.js"}}"></script>
<script src="{{asset "editor/main.js"}}"></script>
{{end}}
+7 -328
View File
@@ -1,161 +1,12 @@
// The page editor: one markdown editor (core.js) inside the edit form, saved
// with the header's SAVE button.
(function () {
var mount = document.getElementById('editor');
var hidden = document.getElementById('editor-content');
if (!mount || !hidden || !window.CM) return;
var form = hidden.closest('form');
var T = EditorTables;
var D = EditorDates;
var M = EditorMovie;
// --- CodeMirror setup ---
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
// highest precedence so they win over CM's default newline/forward-delete.
// Returning false (no table at cursor) lets CM fall back to its default.
function tableKey(fn) {
return function (view) {
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
if (!result) return false;
dispatchFullReplace(result);
return true;
};
}
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (view) {
if (completionOpen()) return false;
return handler(view);
};
}
var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
{ key: 'Tab', run: tableNavKey(T.nextCell) },
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
];
var state = CM.EditorState.create({
doc: hidden.value,
// Start with the caret at the end of the existing content — editing an
// existing page almost always means appending, and the default (start
// of doc) buries the caret behind the leading heading.
selection: { anchor: hidden.value.length },
extensions: [
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
// Enable native browser spellcheck on the contenteditable surface
// (CM6 leaves it off by default). autocapitalize helps prose entry
// on the Android/mobile path; CM's DOM observer absorbs corrections.
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
CM.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(),
CM.autocompletion({ override: [WikiComplete.source] }),
CM.theme,
CM.Prec.highest(CM.keymap.of(tableKeymap)),
CM.keymap.of([].concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab]
)),
],
});
var view = new CM.EditorView({ state: state, parent: mount });
view.focus();
// --- CM document helpers ---
function dispatchFullReplace(result) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.text },
selection: { anchor: result.cursor },
scrollIntoView: true,
});
view.focus();
}
function insertAtCursor(s) {
var sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: s },
selection: { anchor: sel.from + s.length },
scrollIntoView: true,
});
view.focus();
}
function wrap(before, after, placeholder) {
var sel = view.state.selection.main;
var hadSelection = sel.to > sel.from;
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
var insert = before + selected + after;
var anchor, head;
if (hadSelection) {
anchor = head = sel.from + insert.length;
} else {
anchor = sel.from + before.length;
head = anchor + placeholder.length;
}
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: insert },
selection: { anchor: anchor, head: head },
scrollIntoView: true,
});
view.focus();
}
function linePrefix(prefix) {
var sel = view.state.selection.main;
var line = view.state.doc.lineAt(sel.from);
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix },
selection: { anchor: sel.from + prefix.length },
scrollIntoView: true,
});
view.focus();
}
function applyTableOp(fn, arg) {
var text = view.state.doc.toString();
var pos = view.state.selection.main.head;
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
if (result) dispatchFullReplace(result);
}
// Adapter passed to movie.js so it reads/writes the CM document instead of a
// textarea (replace() dispatches a transaction; cursor lands after the block).
var movieCtx = {
getValue: function () { return view.state.doc.toString(); },
replace: function (start, end, text) {
view.dispatch({
changes: { from: start, to: end, insert: text },
selection: { anchor: start + text.length },
scrollIntoView: true,
});
view.focus();
},
};
var editor = MarkdownEditor.create(mount, { doc: hidden.value });
// --- Content sync ---
@@ -163,7 +14,7 @@
// must not depend on CM focus or async state (decision 8). requestSubmit
// fires this listener even for the ALT+SHIFT+S path.
function syncContent() {
hidden.value = view.state.doc.toString();
hidden.value = editor.getValue();
}
// Save POSTs via fetch and then rewrites the *editor's* history entry with
@@ -208,180 +59,8 @@
// --- Actions ---
var actions = {
save: function () { form.requestSubmit(); },
undo: function () { CM.undo(view); view.focus(); },
redo: function () { CM.redo(view); view.focus(); },
deleteline: function () { CM.deleteLine(view); view.focus(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(movieCtx); },
};
editor.actions.save = function () { form.requestSubmit(); };
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// path, no empty/dot segments. Used to gate the modal's INSERT button.
function isValidWikiTarget(p) {
if (!p || p[0] !== '/') return false;
var trimmed = p.replace(/^\/+|\/+$/g, '');
if (trimmed === '') return true;
var segs = trimmed.split('/');
for (var i = 0; i < segs.length; i++) {
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
}
return true;
}
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
// full /_search typeahead plus an optional display-text field, then insert
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
// the folder-scoped completion in wikicomplete.js instead.)
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to);
var container = document.createElement('div');
var targetWrap = document.createElement('div');
var targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.className = 'input';
targetInput.placeholder = 'Page path or search…';
targetWrap.appendChild(targetInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
if (selectedText) displayInput.value = selectedText;
container.appendChild(targetWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: true,
onConfirm: function () {
var target = targetInput.value.trim();
if (!isValidWikiTarget(target)) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
}
targetInput.addEventListener('input', updateConfirm);
window.attachSuggestions(targetInput, {
showFooter: false,
container: targetWrap,
onPick: function (r) {
targetInput.value = '/' + r.path;
updateConfirm();
displayInput.focus();
displayInput.select();
}
});
}
// --- Keyboard shortcut registration ---
var keyMap = {};
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = actions[btn.dataset.action];
if (action) action();
});
if (btn.dataset.key) {
keyMap[btn.dataset.key] = actions[btn.dataset.action];
}
});
// Keep the editor focused when a toolbar button is tapped. Without this the
// button steals focus on mousedown, which dismisses the mobile soft keyboard
// before the action runs (and view.focus() can't reopen it without a direct
// gesture). preventDefault on mousedown blocks the focus shift; click still
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
// dropdown toggles, which also must not pull focus off the editor.
var toolbar = document.querySelector('.editor-toolbar');
if (toolbar) {
toolbar.addEventListener('mousedown', function (e) {
if (e.target.closest('.btn')) e.preventDefault();
});
}
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
// on US, "!" on DE), so fall back to e.code for digit rows.
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
var action = keyMap[key];
if (action) {
e.preventDefault();
action();
}
});
// --- Dropdowns ---
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// menu to the viewport so it escapes the clip, opening upward when there
// isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed';
menu.style.overflowY = 'auto';
var spaceBelow = vh - r.bottom;
var spaceAbove = r.top;
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
menu.style.top = 'auto';
menu.style.bottom = (vh - r.top) + 'px';
menu.style.maxHeight = (spaceAbove - 8) + 'px';
} else {
menu.style.bottom = 'auto';
menu.style.top = r.bottom + 'px';
menu.style.maxHeight = (spaceBelow - 8) + 'px';
}
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px';
}
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
wireDropdown(toggle);
var menu = toggle.parentElement.querySelector('.dropdown-menu');
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
// Runs after wireDropdown's own click handler has toggled is-open.
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
});
// The toolbar and the header SAVE button both name actions.
MarkdownEditor.bind(document.body, function () { return editor.actions; });
})();
+23 -10
View File
@@ -6,22 +6,34 @@ window.EditorTables = (function () {
return s;
}
var segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
// Graphemes rendered two columns wide: emoji-presentation characters, anything
// forced to emoji presentation via VS16, and East Asian wide/fullwidth ranges.
var WIDE = /\p{Emoji_Presentation}|️|[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
var ZERO = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
function displayWidth(s) {
var w = 0;
Array.from(segmenter.segment(s), function (seg) {
var g = seg.segment;
if (!ZERO.test(g)) w += WIDE.test(g) ? 2 : 1;
});
return w;
}
function padLeft(s, w) {
while (s.length < w) s = ' ' + s;
return s;
return repeat(' ', w - displayWidth(s)) + s;
}
function padRight(s, w) {
while (s.length < w) s += ' ';
return s;
return s + repeat(' ', w - displayWidth(s));
}
function padCenter(s, w) {
while (s.length < w) {
s = s + ' ';
if (s.length < w) s = ' ' + s;
}
return s;
var total = w - displayWidth(s);
var left = Math.floor(total / 2);
return repeat(' ', left) + s + repeat(' ', total - left);
}
function parseTableRow(line) {
@@ -101,7 +113,8 @@ window.EditorTables = (function () {
var max = 3;
for (var r = 0; r < rows.length; r++) {
if (r === sepIndex) continue;
if (rows[r][c].length > max) max = rows[r][c].length;
var len = displayWidth(rows[r][c]);
if (len > max) max = len;
}
widths.push(max);
}
+67
View File
@@ -0,0 +1,67 @@
{{/* The markdown editor's toolbar, shared by the page editor and the canvas
node editor. Buttons name an action of editor/core.js (data-action) and its
Alt+Shift shortcut (data-key). */}}
{{define "editor-toolbar"}}<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
</div>
</span>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
</div>
</span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
</div>
</span>
</div>{{end}}
+43 -85
View File
@@ -1,110 +1,68 @@
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
//
// A level-by-level folder/file browser scoped to the path typed so far. It
// fires only once the `[[` token's content begins with `/` (targets are
// absolute; free-text search lives in the toolbar modal instead). The content
// is split into a parent path (up to and including the last `/`) and a partial
// segment (the text after it); the parent's children are fetched from the
// existing `?tree=1` endpoint and filtered to names containing the partial
// (case-insensitive substring).
// Wiki links are search queries, so the popup is the same typeahead the header
// search box uses: the text typed after `[[` goes to `/_search` and each match
// is offered by name. Picking one replaces the query segment with that page's
// name — never a path — so what lands in the document is still a query. The
// trailing `]]` and any `::display` tail are left untouched.
//
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
// deeper; picking a file inserts `<name>` and stops. Only the current segment
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
// for main.js to register via CM's autocompletion().
// Exposes window.WikiComplete.source for main.js to register via CM's
// autocompletion().
window.WikiComplete = (function () {
// treeURL builds the `?tree=1` request URL for an absolute parent path,
// percent-encoding each segment. A leading/trailing slash is tolerated;
// root resolves to `/?tree=1`.
function treeURL(parent) {
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
if (trimmed === '') return '/?tree=1';
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
return '/' + enc + '/?tree=1';
}
// Matches search-suggest.js so both surfaces fire at the same point.
var MIN_QUERY_LEN = 2;
function fetchTree(parent) {
return fetch(treeURL(parent), {
function fetchSuggestions(query) {
return fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
// A 404 means the parent folder doesn't exist (typo, or a path under
// a file) — treat it as "no completions", not an error.
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// makeApply builds the apply() for a chosen entry. It replaces only the
// current segment ([from, to]); the trailing `]]` is untouched, so the
// cursor ends up parked before it. Folders append `/` and re-open the popup
// to drill into the next level; files terminate.
function makeApply(name, kind) {
return function (view, completion, from, to) {
var isFolder = kind === 'folder';
var insert = isFolder ? name + '/' : name;
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
if (isFolder) {
// Re-open after the transaction so the completion plugin sees
// the updated document (the next level's parent path).
setTimeout(function () { CM.startCompletion(view); }, 0);
}
};
// queryEnd returns where the current query segment ends: the first `]`, the
// `::` that starts the display text, or end of line. Replacing only up to
// there keeps a re-edit from eating an existing alias or the closing `]]`.
function queryEnd(context, pos) {
var line = context.state.doc.lineAt(pos);
var to = pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === ']') break;
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
to++;
}
return to;
}
// CM completion source. Activates when `[[` is followed by content that
// begins with `/`. The content is split at its last `/` into a parent path
// and a partial segment; the parent's children are fetched, filtered to
// names containing the partial (case-insensitive substring), and offered
// with name-only labels.
// CM completion source. Activates on `[[` followed by at least
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
// gets nothing.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content[0] !== '/') return null;
if (content.indexOf('::') !== -1) return null;
var query = content.trim();
if (query.length < MIN_QUERY_LEN) return null;
var lastSlash = content.lastIndexOf('/');
var parent = content.slice(0, lastSlash + 1);
var partial = content.slice(lastSlash + 1);
// Replace only the current segment: from the start of the partial up to
// the next `/` or `]` (or end of line). This narrows re-edits inside an
// existing `[[…]]` so drilling doesn't duplicate trailing text.
var from = match.from + 2 + lastSlash + 1;
var line = context.state.doc.lineAt(context.pos);
var to = context.pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === '/' || ch === ']') break;
to++;
}
var from = match.from + 2;
var to = queryEnd(context, context.pos);
return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; }
fetchTree(parent).then(function (resp) {
if (context.aborted || !resp) { resolve(null); return; }
var needle = partial.toLowerCase();
var options = (resp.entries || []).reduce(function (acc, e) {
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
return acc;
}
acc.push({
label: e.name,
type: e.kind === 'folder' ? 'folder' : 'file',
apply: makeApply(e.name, e.kind),
});
return acc;
}, []);
// No validFor: the source re-runs on each keystroke, so every
// edit (more chars, backspace, or a `/` that drills into the
// next folder) re-fetches and re-filters from scratch.
resolve({ from: from, to: to, options: options });
fetchSuggestions(query).then(function (resp) {
if (context.aborted) { resolve(null); return; }
var options = (resp.results || []).map(function (r) {
return { label: r.name, detail: '/' + r.path, apply: r.name };
});
// filter:false — the server already ranked these fuzzily, and
// CM's own prefix filter would drop the near-misses (typos,
// token reorderings) that make the fuzzy search worth having.
// No validFor either: every keystroke re-queries from scratch.
resolve({ from: from, to: to, options: options, filter: false });
}).catch(function () {
resolve(null);
});
+63 -9
View File
@@ -1,10 +1,64 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that
// chart's query parameter updated. Plain GET navigation — each range is a
// distinct, bookmarkable view, so no postReplace/history handling is needed.
document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
window.location.href = url.toString();
// Fitness dashboard chart hover. The range dropdowns and "absolute"
// checkboxes are htmx (assets/fitness/main.html): they swap in only their
// chart.
// 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;
// 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';
});
}
+16 -7
View File
@@ -1,20 +1,26 @@
{{/* A chart's controls re-fetch only that chart (fitnessHandler.serveFragment),
sending every chart's controls so the server can update the page URL. */}}
{{define "fitnessChart"}}
<section class="fitness-chart panel">
<section class="fitness-chart panel" hx-get="./" hx-vals='{"chart": "{{.Param}}"}' hx-trigger="change" hx-include="closest .fitness-dash" hx-target="this" hx-swap="outerHTML" hx-sync="this:replace">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
<div class="row fitness-controls">
<label class="fitness-absolute"><input type="checkbox" name="{{.Param}}-absolute" value="1"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" name="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
</div>
{{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p>
{{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}}
{{range .XTicks}}
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -23,13 +29,16 @@
<polyline class="chart-line" points="{{.}}"/>
{{end}}
{{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/>
{{end}}
{{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}}
</section>
{{end}}
@@ -40,4 +49,4 @@
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}}
</div>
<script src="/_/fitness/fitness.js"></script>
<script src="{{asset "fitness/fitness.js"}}"></script>
+15 -7
View File
@@ -16,6 +16,12 @@
e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break;
case 'H':
var maint = document.querySelector('[data-action="maint-menu"]');
if (!maint) return;
e.preventDefault();
maint.click();
break;
case 'F':
var input = document.querySelector('.search-input');
if (!input) return;
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
['actions-menu', 'maint-menu'].forEach(function (action) {
var trigger = document.querySelector('[data-action="' + action + '"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
});
});
+141
View File
@@ -0,0 +1,141 @@
// 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';
}
// 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;
}
// 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 a page target
// in like a navigation; anything else (or a target that is not a page,
// like a new canvas) gets replaceState + reload (not location.replace: a
// target differing only by #fragment would otherwise not re-fetch).
htmx.location = {
set href(target) {
if (boosting() && isPageURL(new URL(target, location.href))) {
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;
// 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" shape-rendering="crispEdges">
<path d="M1 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/>
</svg>

After

Width:  |  Height:  |  Size: 250 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 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

+75 -34
View File
@@ -4,22 +4,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/history-nav.js"></script>
<script src="/_/scroll-sync.js" defer></script>
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
<link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "hx.js"}}" defer></script>
<script src="{{asset "modal.js"}}"></script>
<script src="{{asset "global-shortcuts.js"}}"></script>
<script src="{{asset "history-nav.js"}}"></script>
<script src="{{asset "scroll-sync.js"}}" defer></script>
<script src="{{asset "search-suggest.js"}}" defer></script>
<script src="{{asset "tree-picker.js"}}"></script>
<script src="{{asset "companion.js"}}" defer></script>
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="{{asset "page/actions.js"}}"></script>
<script src="{{asset "search/actions.js"}}"></script>{{end}}
</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. A view can opt out by
overriding "boost" (the canvas does: it saves on pagehide). */}}
<body{{if not .EditMode}}{{block "boost" .}} hx-boost="true"{{end}}{{end}}>
<header>
<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>
@@ -31,36 +37,71 @@
{{end}}
<div class="header-actions row">
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
{{maintenanceWidget}}
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
<div class="actions-special"></div>
{{template "headerActions" . }}
{{template "page-actions" .}}
</div>
</span>
{{end}}
</div>
</header>
<div class="shell">
{{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>
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
{{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body>
</html>
{{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 () {
var backdrop = null;
var modal = null;
var titleEl = null;
var bodyEl = null;
var cancelBtn = null;
var confirmBtn = null;
var footerEl = null;
var prevFocus = null;
var currentOpts = null;
var onKeydown = null;
var current = null; // { backdrop, prevFocus, onKeydown }
var drag = null; // { modal, startX, startY, originX, originY }
function build() {
backdrop = document.createElement('div');
// show mounts backdrop (whose child is the .modal) into parent and makes
// 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';
modal = document.createElement('div');
var modal = document.createElement('div');
modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div');
header.className = 'panel-header';
titleEl = document.createElement('span');
var titleEl = document.createElement('span');
titleEl.textContent = opts.title || '';
header.appendChild(titleEl);
bodyEl = document.createElement('div');
var bodyEl = document.createElement('div');
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) {
bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') {
@@ -124,14 +115,24 @@
var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
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.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
footerEl.textContent = '';
var footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
if (opts.swapButtons) {
footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn);
@@ -140,7 +141,20 @@
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 () {
if (cancelOpts.autofocus) {
@@ -156,22 +170,6 @@
}
}, 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 {
close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
@@ -179,20 +177,30 @@
};
}
function close() {
if (!backdrop || !backdrop.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
}
backdrop.parentNode.removeChild(backdrop);
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
// is open (e.g. the merge step replacing the move dialog); focus returns
// to where it was before the first one opened.
var hostPrevFocus = null;
// History restores swap without event details; they never target the host.
function isHost(e) {
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
}
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.closeModal = close;
+7 -131
View File
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
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) {
var input = document.createElement('input');
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() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return;
var segs = current.split('/').filter(Boolean);
var currentName = segs[segs.length - 1] || '';
var parent = '/' + segs.slice(0, -1).join('/');
if (parent === '/') parent = '/';
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
openTreePicker({
title: 'Move — new parent?',
@@ -123,68 +54,13 @@ function movePage() {
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (newParent) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = currentName;
var linksCheckbox = document.createElement('input');
linksCheckbox.type = 'checkbox';
linksCheckbox.id = 'move-update-links';
var linksLabel = document.createElement('label');
linksLabel.htmlFor = linksCheckbox.id;
linksLabel.className = 'row';
linksLabel.appendChild(linksCheckbox);
linksLabel.appendChild(document.createTextNode('Update links'));
var body = document.createDocumentFragment();
body.appendChild(input);
body.appendChild(linksLabel);
openModal({
title: 'Move — new name?',
body: body,
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);
if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
}
}
// The server renders the name step and handles what follows
// (merge confirmation, errors, redirect).
closeModal();
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
target: '#modal-host',
values: { parent: newParent }
});
}
});
}
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 () {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
// Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
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");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
});
var item = document.createElement("button");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+93
View File
@@ -0,0 +1,93 @@
{{/* 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 "canvas"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?canvas" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">New canvas — name?</span></div>
<div class="panel-body">
<input class="input" type="text" name="name" placeholder="Canvas name" required autofocus />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit">CREATE</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}}
+14 -18
View File
@@ -1,13 +1,12 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" hx-get="?dialog=canvas" hx-target="#modal-host" title="New canvas on this page"><span class="btn-icon">{{icon "canvas"}}</span><span class="btn-label">CANVAS</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
<button class="btn btn-block danger" 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}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
{{end}}{{end}}
{{define "content"}}
@@ -17,8 +16,11 @@
{{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if not (or .Content .SpecialContent)}}
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
{{end}}
{{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
<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"}}
<div class="thumb-grid">
{{range .Entries}}
@@ -41,21 +43,15 @@
</tbody>
</table>
{{end}}
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
{{else if not .Content}}
{{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
{{end}}
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script>
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script>
<script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
{{end}}
{{if .Content}}
<script src="/_/page/sections.js"></script>
<script src="{{asset "page/sections.js"}}"></script>
{{end}}
<script src="/_/page/contents.js"></script>
<script src="{{asset "page/contents.js"}}"></script>
{{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
-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 () {
var content = document.querySelector("main");
if (!content) return;
// Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var content = document.querySelector("main");
if (!content) return;
var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return;
var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return;
var nav = document.createElement("nav");
nav.className = "toc panel";
var nav = document.createElement("nav");
nav.className = "toc panel";
var header = document.createElement("div");
header.className = "panel-header";
header.textContent = "Contents";
nav.appendChild(header);
var header = document.createElement("div");
header.className = "panel-header";
header.textContent = "Contents";
nav.appendChild(header);
var list = document.createElement("ul");
headings.forEach(function (h) {
if (!h.id) return;
var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
var list = document.createElement("ul");
headings.forEach(function (h) {
if (!h.id) return;
var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
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 name="viewport" content="width=device-width, initial-scale=1" />
<title>Save link</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="stylesheet" href="/_/style.css" />
<link rel="icon" href="{{asset "favicon.ico"}}" />
<link rel="stylesheet" href="{{asset "style.css"}}" />
<style>
body { padding: 0.8rem; }
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
+10 -5
View File
@@ -45,17 +45,22 @@
}
// 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;
center.addEventListener('scroll', function () {
if (pending) return;
document.addEventListener('scroll', function (e) {
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
center = document.querySelector('.center');
if (!center) return;
var m = maxScroll();
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) {}
});
}, { passive: true });
}, { capture: true, passive: true });
})();
+73 -246
View File
@@ -1,63 +1,18 @@
// search-suggest.js — instant typeahead dropdown.
//
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
// search box and the editor's "Insert link" modal. Fetching is htmx: the input
// 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:
// onPick(result) — called when the user selects a row
// onShowAll(query) — optional; called when the footer row activates
// onPick({name}) — optional; called when the user selects a row.
// Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode)
(function () {
var DEBOUNCE_MS = 100;
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;
}
var seq = 0;
function attachSuggestions(inputEl, opts) {
if (!inputEl) return;
@@ -68,237 +23,116 @@
var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown);
function makeRow(cls, tabbable) {
var tr = document.createElement('tr');
tr.className = cls;
if (tabbable) tr.setAttribute('tabindex', '0');
var td = document.createElement('td');
tr.appendChild(td);
return { tr: tr, td: td };
// htmx sends the input's value under its name.
if (!inputEl.name) inputEl.name = 'q';
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
inputEl.setAttribute('hx-sync', 'this:replace');
inputEl.setAttribute('hx-target', '#' + dropdown.id);
htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
}
var state = {
results: [],
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 isOpen() {
return dropdown.classList.contains('is-open');
}
function open() {
state.open = true;
render();
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
}
function close() {
state.open = false;
state.activeIdx = -1;
render();
dropdown.classList.remove('is-open');
setActive(-1);
}
function fetchResults(query) {
var seq = ++state.reqSeq;
fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
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;
function setActive(idx) {
var rs = rows();
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
activeIdx = idx;
if (rs[idx]) {
rs[idx].classList.add('is-active');
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
}
function moveActive(delta) {
var n = rowCount();
var n = rows().length;
if (n === 0) return;
var next = state.activeIdx + delta;
var next = activeIdx + delta;
if (next < 0) next = n - 1;
if (next >= n) next = 0;
state.activeIdx = next;
render();
// Keep the active row in view.
var active = dropdown.querySelector('tr.is-active');
if (active && active.scrollIntoView) {
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
setActive(next);
}
// pick activates a row: onPick for data rows when given, otherwise
// (and always for the footer) the row's link.
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() {
if (state.activeIdx < 0) return false;
if (isFooterIdx(state.activeIdx)) {
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)) {
dropdown.addEventListener('htmx:afterSwap', function () {
activeIdx = -1;
if (document.activeElement === inputEl && dropdown.firstElementChild) {
open();
} else {
close();
}
});
inputEl.addEventListener('blur', function () {
// Delay so click/mousedown on a row still resolves.
state.blurTimer = setTimeout(close, 150);
// mousedown keeps focus in the input so blur doesn't close the
// 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) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault();
close();
} 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();
activateCurrent();
pick(row);
}
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') {
if (!state.open || rowCount() === 0) return;
if (rows().length === 0) return;
e.preventDefault();
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 {
close: close,
destroy: function () {
@@ -310,14 +144,7 @@
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 () {
var input = document.querySelector('header .search-input');
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
});
})();
+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 () {
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');
if (input && input.value) {
input.focus();
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
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 "content"}}
@@ -43,7 +41,7 @@
{{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index"><span class="btn-icon">{{icon "rebuild"}}</span><span class="btn-label">REBUILD INDEX</span></button>
{{end}}
{{define "footerExtras"}}
+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}}
+427 -14
View File
@@ -46,6 +46,37 @@
The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */
--reading-width: 60rem;
/* JSON Canvas preset colors "1"–"6": red, orange, yellow, green, cyan,
purple. */
--canvas-1: #e0525b;
--canvas-2: #e08a3a;
--canvas-3: #d6c94d;
--canvas-4: #5fb35b;
--canvas-5: #01b6c4;
--canvas-6: #a46ad6;
}
/* 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
@@ -75,8 +106,6 @@ a { color: var(--text); text-decoration: none; }
a:hover { color: var(--link-hover); }
.content a { color: var(--link); }
.content a:hover { color: var(--link-hover); }
.content a.broken { color: var(--link-hover); text-decoration: line-through; }
.content a.broken:hover { color: var(--link-hover); }
hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
@@ -221,6 +250,20 @@ footer {
.danger { color: var(--danger); }
.danger:hover { color: var(--danger-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 ===
.input baseline is shared by search-input, modal inputs, and the editor
@@ -391,6 +434,12 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.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) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -409,17 +458,19 @@ main > h2 {
}
.suggest-dropdown.is-open { display: block; }
.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 { color: var(--text); }
.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 ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
than breaking into stacked rows. A horizontal-scroll container also clips
overflow-y, so open dropdown menus are pinned to the viewport via JS
(editor/main.js pinMenu) to escape the clip. */
(editor/core.js pinMenu) to escape the clip. */
.editor-toolbar {
display: flex;
flex-wrap: nowrap;
@@ -709,17 +760,33 @@ aside.sidebar:empty { display: none; }
padding: 0.25rem var(--space-1);
cursor: pointer;
}
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
.tree-row.is-selected {
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row:hover { background: var(--bg-panel-hover); }
.tree-row.is-active > .tree-name { color: var(--secondary); }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); }
.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-children {
margin-left: var(--space-2);
@@ -799,13 +866,46 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current,
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { color: var(--primary-hover); }
/* Calendar events and weight under a diary day heading. */
.diary-day-cal { margin: var(--space-3) 0 0; }
.diary-day-cal p { margin: 0; }
/* === Calendars ===
Header maintenance chip + dropdown, and the calendars folder page. */
.maint-toggle { display: inline-flex; align-items: center; }
.maint-count { margin-left: 0.3rem; }
.maint-toggle.is-idle { color: var(--text-muted); }
.maint-menu {
min-width: 20rem;
max-width: calc(100vw - 2rem);
padding: var(--space-2) var(--space-3);
white-space: normal;
}
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
.maint-list {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-2);
row-gap: 0.15rem;
font-size: var(--font-sm);
}
.maint-list > .muted { grid-column: 1 / -1; }
.maint-list + .panel-header { margin-top: var(--space-3); }
.maint-when { color: var(--text-muted); white-space: nowrap; }
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
.cal-scroll { overflow-x: auto; }
.cal-table .nowrap { white-space: nowrap; }
.cal-error { color: var(--danger); font-size: var(--font-sm); }
.cal-sync { margin-top: var(--space-3); }
/* === Fitness dashboard ===
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
colors and strokes are applied here via classes so the inline SVG follows
the theme palette. */
.fitness-chart { padding: var(--space-3); }
.fitness-chart { position: relative; padding: var(--space-3); }
.fitness-chart-header { margin-bottom: var(--space-2); }
.fitness-controls { gap: var(--space-3); }
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
.fitness-range { width: auto; font-size: var(--font-sm); }
.fitness-empty {
border: var(--border-dashed);
@@ -819,8 +919,319 @@ aside.sidebar:empty { display: none; }
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
.fitness-svg .chart-dot { fill: var(--link); }
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
.fitness-svg.is-dense .chart-dot { display: none; }
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; }
.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; }
.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; }
.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; }
.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); }
.fitness-tip {
position: absolute;
display: flex;
flex-direction: column;
padding: var(--space-1) var(--space-2);
background: var(--bg);
border: var(--border);
font-size: var(--font-sm);
white-space: nowrap;
pointer-events: none;
}
.fitness-tip[hidden] { display: none; }
/* === Canvas ===
The canvas view (canvas/main.html) fills the shell. The world is one
transformed layer holding nodes in canvas units; canvas.js sets its
transform and --z (the zoom), which keeps handles and strokes a constant
size on screen. Groups sit below the edges, everything else above. */
.canvas-app {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.canvas-error { padding: var(--space-2) var(--space-4); color: var(--danger); }
/* The stage holds the viewport and what floats over it: the status line,
the context menu and the edit window. */
.canvas-stage { position: relative; flex: 1 1 auto; min-height: 0; }
.canvas-statusbar {
position: absolute;
left: var(--space-2);
bottom: var(--space-2);
z-index: 10;
display: flex;
gap: var(--space-3);
padding: 0 var(--space-2);
background: var(--bg);
pointer-events: none;
}
.canvas-status.is-error { color: var(--danger); pointer-events: auto; }
.canvas-editor-toolbar[hidden] { display: none; }
.canvas-viewport {
position: absolute;
inset: 0;
overflow: hidden;
touch-action: none;
outline: none;
-webkit-touch-callout: none;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1.5px);
}
.canvas-viewport.is-far { background-image: none; }
.canvas-viewport.is-panning { cursor: grabbing; }
.canvas-world, .canvas-nodes, .canvas-edge-labels { position: absolute; left: 0; top: 0; }
.canvas-world { transform-origin: 0 0; }
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
pointer-events: none;
}
.canvas-hint[hidden] { display: none; }
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: color-mix(in srgb, var(--primary-hover) 10%, transparent);
pointer-events: none;
}
/* Preset colors, for nodes, edges and the color menu's swatches. A hex color
arrives as an inline --node-color / --edge-color. */
.canvas-app [data-color="1"] { --node-color: var(--canvas-1); --edge-color: var(--canvas-1); }
.canvas-app [data-color="2"] { --node-color: var(--canvas-2); --edge-color: var(--canvas-2); }
.canvas-app [data-color="3"] { --node-color: var(--canvas-3); --edge-color: var(--canvas-3); }
.canvas-app [data-color="4"] { --node-color: var(--canvas-4); --edge-color: var(--canvas-4); }
.canvas-app [data-color="5"] { --node-color: var(--canvas-5); --edge-color: var(--canvas-5); }
.canvas-app [data-color="6"] { --node-color: var(--canvas-6); --edge-color: var(--canvas-6); }
.canvas-node {
--node-color: var(--secondary);
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
border: 1px solid var(--node-color);
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), var(--bg-panel));
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.canvas-node[data-color],
.canvas-node[style*="--node-color"] { --node-tint: 14%; }
.canvas-node.is-selected { outline: calc(2px / var(--z, 1)) solid var(--primary-hover); outline-offset: calc(3px / var(--z, 1)); }
.canvas-node.is-pending { opacity: 0.6; }
.canvas-node-group {
z-index: 0;
border-style: dashed;
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), transparent);
isolation: isolate;
}
.canvas-node-group[data-color],
.canvas-node-group[style*="--node-color"] { --node-tint: 8%; }
.canvas-node-label {
position: absolute;
bottom: 100%;
left: 0;
max-width: 100%;
padding-bottom: 0.15em;
font-size: var(--font-sm);
color: var(--node-color);
}
.canvas-node-group > .canvas-node-label { font-size: 1.1rem; }
.canvas-node-label a { color: inherit; }
.canvas-node-label a:hover { color: var(--link-hover); }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: var(--space-2) var(--space-3);
word-break: break-word;
}
.canvas-node-body.content { margin: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body[hidden] { display: none; }
.canvas-node-group > .canvas-node-body { padding: 0; overflow: hidden; }
.canvas-node-file > .canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; }
.canvas-media { display: block; width: 100%; height: 100%; }
.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
video.canvas-media { object-fit: contain; background: #000; }
audio.canvas-media { height: auto; }
.canvas-card {
display: flex;
align-items: center;
gap: var(--space-2);
height: 100%;
min-width: 0;
color: var(--link);
}
.canvas-card:hover { color: var(--link-hover); }
.canvas-card-icon { flex: none; }
.canvas-card-text { display: flex; flex-direction: column; min-width: 0; }
.canvas-missing { color: var(--danger); font-size: var(--font-sm); }
.canvas-group-bg { position: absolute; inset: 0; z-index: -1; background-position: center; opacity: 0.5; }
.canvas-group-bg-cover { background-size: cover; background-repeat: no-repeat; }
.canvas-group-bg-ratio { background-size: contain; background-repeat: no-repeat; }
.canvas-group-bg-repeat { background-repeat: repeat; }
/* Handles: connect from a side, resize from the corner. */
.canvas-handle, .canvas-resize { position: absolute; display: none; z-index: 1; }
.canvas-handle {
width: calc(14px / var(--z, 1));
height: calc(14px / var(--z, 1));
border-radius: 50%;
border: calc(2px / var(--z, 1)) solid var(--primary-hover);
background: var(--bg);
transform: translate(-50%, -50%);
cursor: crosshair;
}
.canvas-handle[data-side="top"] { left: 50%; top: 0; }
.canvas-handle[data-side="right"] { left: 100%; top: 50%; }
.canvas-handle[data-side="bottom"] { left: 50%; top: 100%; }
.canvas-handle[data-side="left"] { left: 0; top: 50%; }
.canvas-resize {
right: 0;
bottom: 0;
width: calc(16px / var(--z, 1));
height: calc(16px / var(--z, 1));
background: linear-gradient(135deg, transparent 55%, var(--primary-hover) 55%);
cursor: nwse-resize;
}
.canvas-node.is-selected > .canvas-handle,
.canvas-node.is-selected > .canvas-resize { display: block; }
@media (hover: hover) {
.canvas-node:hover > .canvas-handle { display: block; }
}
/* Edges: an SVG sized to their bounding box (canvas.js). The wide invisible
hit path makes thin lines easy to click. */
.canvas-edges { position: absolute; z-index: 1; overflow: visible; pointer-events: none; }
.canvas-edge { --edge-color: var(--text-muted); }
.canvas-edge-line {
fill: none;
stroke: var(--edge-color);
stroke-width: max(2px, calc(1.5px / var(--z, 1)));
}
.canvas-edge-hit {
fill: none;
stroke: transparent;
stroke-width: calc(16px / var(--z, 1));
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-arrow { fill: var(--edge-color); }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); }
.canvas-edge.is-selected .canvas-edge-arrow { fill: var(--primary-hover); }
.canvas-edge.is-draft .canvas-edge-line { stroke: var(--primary-hover); stroke-dasharray: 6 4; }
.canvas-edge-label {
--edge-color: var(--text-muted);
position: absolute;
z-index: 3;
transform: translate(-50%, -50%);
padding: 0 var(--space-1);
background: var(--bg);
color: var(--edge-color);
font-size: var(--font-sm);
white-space: pre;
cursor: pointer;
}
.canvas-edge-label.is-selected { color: var(--primary-hover); }
/* Context menu (canvas.js openMenu): fixed at the pointer. */
.canvas-menu {
position: fixed;
z-index: 100;
min-width: 12rem;
padding: var(--space-1) 0;
font-size: var(--font-sm);
}
.canvas-menu .btn-block { padding: 0.3rem var(--space-3); }
.canvas-menu .btn:disabled { color: var(--text-muted); cursor: default; }
.canvas-menu-sep { border-top: var(--border-dashed); margin: var(--space-1) 0; }
.canvas-menu-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.3rem var(--space-3);
}
.canvas-menu-row > .muted { min-width: 3rem; }
.canvas-swatch {
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: var(--border);
background: var(--node-color, transparent);
cursor: pointer;
}
.canvas-swatch:not([data-color]):not(input) {
background: linear-gradient(to top right, transparent 45%, var(--text-muted) 45%, var(--text-muted) 55%, transparent 55%);
}
.canvas-swatch.is-current { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
input.canvas-swatch { background: none; }
/* Edit window (canvas.js openWindow): not modal, placed beside its card;
small screens dock it to the bottom. */
.canvas-window {
position: absolute;
z-index: 20;
width: 24rem;
max-width: calc(100% - 2rem);
max-height: calc(100% - 2rem);
display: flex;
flex-direction: column;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4);
}
.canvas-window.is-wide { width: 34rem; height: 24rem; resize: both; overflow: hidden; min-width: 18rem; min-height: 12rem; }
.canvas-window .panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
font-size: var(--font-sm);
color: var(--text);
padding: 0.4rem var(--space-4);
margin-bottom: 0;
cursor: move;
user-select: none;
}
.canvas-window .panel-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
.canvas-window .panel-footer { justify-content: flex-end; }
.canvas-window .input { background: var(--bg); }
.canvas-field { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.canvas-field-label { font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.canvas-field-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
.canvas-field-row > :first-child { flex: 1 1 auto; min-width: 0; }
.canvas-field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.canvas-fields { display: flex; flex-direction: column; gap: var(--space-3); }
.canvas-window.is-wide .panel-body { padding: 0; }
.canvas-window-editor { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.canvas-window-editor .editor-toolbar { position: static; border-left: none; border-right: none; border-top: none; }
.canvas-window-cm { flex: 1 1 auto; min-height: 0; display: flex; }
.canvas-window-cm .cm-editor { flex: 1 1 auto; min-width: 0; border: none; }
.canvas-window-cm .cm-editor .cm-scroller { min-height: 0; }
.canvas-window-cm .cm-editor .cm-content { padding: var(--space-2) var(--space-3); }
@media (max-width: 600px) {
.canvas-window, .canvas-window.is-wide {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
width: auto;
max-width: none;
height: auto;
max-height: 60vh;
resize: none;
padding-bottom: env(safe-area-inset-bottom);
}
.canvas-window.is-wide { height: 50vh; }
.canvas-window .panel-header { cursor: default; }
.canvas-menu .btn-block { min-height: 2.5rem; align-items: center; }
.canvas-swatch { width: 1.6rem; height: 1.6rem; }
}
/* === Responsive === */
@media (max-width: 1100px) {
@@ -842,7 +1253,9 @@ aside.sidebar:empty { display: none; }
@media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
.actions-menu .btn-block { width: auto; padding: 0;}
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; }
.actions-menu .btn-icon { display: inline-block; }
.actions-menu .btn-label { display: none; }
}
@media (max-width: 600px) {
+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 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) {
opts = opts || {};
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 treeEl = document.createElement('div');
treeEl.className = 'tree-picker panel';
treeEl.textContent = '…';
var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted';
selectedPathEl.textContent = '\u00a0';
selectedPathEl.textContent = ' ';
container.appendChild(treeEl);
container.appendChild(selectedPathEl);
var selected = null; // { path, kind, rowEl }
function checked() {
return treeEl.querySelector('input[name="pick"]:checked');
}
var handle = openModal({
title: opts.title || 'Pick',
@@ -46,236 +32,34 @@
label: opts.confirmLabel || 'SELECT',
initiallyDisabled: true,
onConfirm: function () {
if (!selected) return;
var pick = checked();
if (!pick) return;
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) {
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
selected = { path: path, kind: kind, rowEl: rowEl };
rowEl.classList.add('is-selected');
selectedPathEl.textContent = path;
handle.setConfirmDisabled(false);
function sync() {
var pick = checked();
selectedPathEl.textContent = pick ? pick.value : ' ';
handle.setConfirmDisabled(!pick);
}
treeEl.addEventListener('change', sync);
function isSelectable(kind) {
if (mode === 'any') return true;
if (mode === 'folder') return kind === 'folder';
if (mode === 'file') return kind === 'file';
return false;
}
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
// for files and idempotent for folders (resolves with the already-
// loaded children on repeat calls).
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');
var url = '/?tree&pick=' + mode +
(opts.hideFiles ? '' : '&files=1') +
(opts.allowRoot !== false ? '&root=1' : '') +
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
sync();
var pick = checked();
if (pick) {
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
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;
}
-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
}
+586
View File
@@ -0,0 +1,586 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
)
// Canvases are JSON Canvas 1.0 files (https://jsoncanvas.org) stored as
// <name>.canvas inside a page's folder, so a page can hold any number of them.
//
// GET /Page/Board.canvas the canvas view (canvas/main.html)
// GET /Page/Board.canvas?raw the file itself
// POST /Page/Board.canvas?save the whole document (autosave, canvas.js)
// POST /Page/Board.canvas?render one node → its rendered element
// POST /Page/?canvas create a canvas (dialog "canvas")
//
// The canvas URL sits next to the page, so relative links and embeds in text
// nodes resolve against the page folder exactly like they do in index.md.
//
// The server only reads the fields it renders. Saves write the client's JSON
// back as sent (reindented), so fields this code does not know survive.
const canvasExt = ".canvas"
var canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets,
"assets/layout.html", "assets/editor/toolbar.html", "assets/canvas/main.html", "assets/canvas/node.html"))
func isCanvasFile(name string) bool {
return strings.EqualFold(path.Ext(name), canvasExt)
}
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Color string `json:"color,omitempty"`
Text string `json:"text,omitempty"`
File string `json:"file,omitempty"`
Subpath string `json:"subpath,omitempty"`
URL string `json:"url,omitempty"`
Label string `json:"label,omitempty"`
Background string `json:"background,omitempty"`
BackgroundStyle string `json:"backgroundStyle,omitempty"`
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
FromEnd string `json:"fromEnd,omitempty"`
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
ToEnd string `json:"toEnd,omitempty"`
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
}
type canvasDoc struct {
Nodes []canvasNode `json:"nodes"`
Edges []canvasEdge `json:"edges"`
}
// emptyCanvas is what a new canvas file holds, and what an empty file reads as.
var emptyCanvas = []byte("{\n\t\"nodes\": [],\n\t\"edges\": []\n}\n")
// parseCanvas decodes and validates a canvas document. An empty file is an
// empty canvas.
func parseCanvas(raw []byte) (canvasDoc, error) {
var doc canvasDoc
if len(bytes.TrimSpace(raw)) == 0 {
return doc, nil
}
// The top level must be an object; Unmarshal into a struct would accept
// `null` silently.
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return doc, fmt.Errorf("not a JSON Canvas object: %v", err)
}
if err := json.Unmarshal(raw, &doc); err != nil {
return doc, fmt.Errorf("invalid canvas: %v", err)
}
return doc, validateCanvas(doc)
}
var canvasHexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// canvasColor splits a canvas color into a preset ("1"–"6") or a hex value.
// Anything else is no color.
func canvasColor(c string) (preset string, hex template.CSS) {
if len(c) == 1 && c[0] >= '1' && c[0] <= '6' {
return c, ""
}
if canvasHexColorRe.MatchString(c) {
return "", template.CSS(c)
}
return "", ""
}
func validCanvasColor(c string) bool {
p, h := canvasColor(c)
return c == "" || p != "" || h != ""
}
func validCanvasSide(s string) bool {
switch s {
case "", "top", "right", "bottom", "left":
return true
}
return false
}
func validCanvasEnd(s string) bool {
return s == "" || s == "none" || s == "arrow"
}
// validateCanvas checks what the renderer and editor rely on: unique ids,
// known node types with their required field, finite geometry, and edges
// between existing nodes.
func validateCanvas(doc canvasDoc) error {
ids := map[string]bool{}
for i, n := range doc.Nodes {
if n.ID == "" {
return fmt.Errorf("node %d has no id", i)
}
if ids[n.ID] {
return fmt.Errorf("duplicate id %q", n.ID)
}
ids[n.ID] = true
for _, v := range []float64{n.X, n.Y, n.Width, n.Height} {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("node %q has invalid geometry", n.ID)
}
}
switch n.Type {
case "text", "group":
case "file":
if n.File == "" {
return fmt.Errorf("file node %q has no file", n.ID)
}
case "link":
if n.URL == "" {
return fmt.Errorf("link node %q has no url", n.ID)
}
default:
return fmt.Errorf("node %q has unknown type %q", n.ID, n.Type)
}
if !validCanvasColor(n.Color) {
return fmt.Errorf("node %q has invalid color %q", n.ID, n.Color)
}
}
edgeIDs := map[string]bool{}
for i, e := range doc.Edges {
if e.ID == "" {
return fmt.Errorf("edge %d has no id", i)
}
if edgeIDs[e.ID] {
return fmt.Errorf("duplicate edge id %q", e.ID)
}
edgeIDs[e.ID] = true
if !ids[e.FromNode] || !ids[e.ToNode] {
return fmt.Errorf("edge %q connects a missing node", e.ID)
}
if !validCanvasSide(e.FromSide) || !validCanvasSide(e.ToSide) {
return fmt.Errorf("edge %q has an invalid side", e.ID)
}
if !validCanvasEnd(e.FromEnd) || !validCanvasEnd(e.ToEnd) {
return fmt.Errorf("edge %q has an invalid end", e.ID)
}
if !validCanvasColor(e.Color) {
return fmt.Errorf("edge %q has invalid color %q", e.ID, e.Color)
}
}
return nil
}
// canvasVersion identifies one exact file content. Saves carry the version
// they were based on; a mismatch means someone else changed the file.
func canvasVersion(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:8])
}
// canvasMu serializes the version check and write of canvas saves.
var canvasMu sync.Mutex
// === Rendering ===
// canvasNodeView is one node element (canvas/node.html). Geometry is in
// canvas units; the client positions the world, not the nodes.
type canvasNodeView struct {
ID, Type string
X, Y, W, H int
Preset string
Hex template.CSS
Label string
LabelURL string
Markdown bool // body is rendered markdown (.content styling)
Body template.HTML
}
// canvasBody* feed the node body templates in canvas/node.html.
type canvasMediaBody struct {
URL, Thumb, Kind string
}
type canvasCardBody struct {
Icon template.HTML
Name, URL string
Host string
Label string // a link's display text, shown instead of its URL
}
type canvasGroupBody struct {
URL string
Style string
}
func (h *handler) renderCanvasNode(n canvasNode) canvasNodeView {
v := canvasNodeView{
ID: n.ID,
Type: n.Type,
X: int(math.Round(n.X)),
Y: int(math.Round(n.Y)),
W: int(math.Round(n.Width)),
H: int(math.Round(n.Height)),
}
v.Preset, v.Hex = canvasColor(n.Color)
switch n.Type {
case "text":
v.Markdown = true
v.Body = template.HTML(canvasTaskCheckboxes(convertMarkdown([]byte(n.Text), "")))
case "file":
h.renderCanvasFile(&v, n)
case "link":
host := n.URL
if u, err := url.Parse(n.URL); err == nil && u.Host != "" {
host = u.Host
}
v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host, Label: strings.TrimSpace(n.Label)})
case "group":
v.Label = n.Label
if n.Background != "" {
if rel, _, ok := h.canvasFilePath(n.Background); ok {
style := n.BackgroundStyle
if style != "ratio" && style != "repeat" {
style = "cover"
}
v.Body = execNodeTemplate("canvas-group-bg", canvasGroupBody{URL: fileURL(rel), Style: style})
}
}
}
return v
}
// canvasFilePath resolves a file node's path. Paths are relative to the wiki
// root (the spec's "vault"); a leading slash is accepted. Dot segments are
// refused like everywhere else.
func (h *handler) canvasFilePath(p string) (rel, fsPath string, ok bool) {
rel = strings.Trim(path.Clean("/"+strings.ReplaceAll(p, "\\", "/")), "/")
if hasDotSegment(rel) {
return "", "", false
}
return rel, filepath.Join(h.root, filepath.FromSlash(rel)), true
}
func (h *handler) renderCanvasFile(v *canvasNodeView, n canvasNode) {
rel, fsPath, ok := h.canvasFilePath(n.File)
v.Label = path.Base("/" + rel)
info, err := os.Stat(fsPath)
if !ok || err != nil {
v.Body = execNodeTemplate("canvas-missing", n.File)
return
}
if info.IsDir() {
// A folder is a page: show its index.md.
v.LabelURL = pageURL(rel)
raw, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
if heading := extractFirstHeading(raw); heading != "" {
v.Label = heading
}
canvasMarkdownBody(v, raw, n.Subpath, dirBase(rel))
return
}
u := fileURL(rel)
v.LabelURL = u
name := info.Name()
ext := strings.ToLower(path.Ext(name))
switch {
case ext == ".md":
raw, _ := os.ReadFile(fsPath)
canvasMarkdownBody(v, raw, n.Subpath, dirBase(path.Dir("/"+rel)))
case isImageFile(name) || ext == ".svg" || ext == ".webp":
thumb := u
if hasThumbnail(name) {
thumb = u + "?w=800"
}
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Thumb: thumb, Kind: "image"})
case isVideoFile(name):
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "video"})
case ext == ".mp3" || ext == ".flac" || ext == ".ogg" || ext == ".wav":
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "audio"})
default:
v.Body = execNodeTemplate("canvas-file", canvasCardBody{Icon: fileIcon(name), Name: name, URL: u})
}
}
// dirBase is the base URL path for markdown that lives in the folder rel
// (wiki-relative, "" or "/" for the root).
func dirBase(rel string) string {
rel = strings.Trim(rel, "/")
if rel == "" {
return "/"
}
return "/" + rel + "/"
}
// canvasMarkdownBody renders a markdown file node: the whole file, or the
// section its subpath names. A subpath that names no heading says so on the
// card rather than showing the whole file (as jsoncanvas-desktop does).
func canvasMarkdownBody(v *canvasNodeView, raw []byte, subpath, base string) {
part, ok := canvasSubpath(raw, subpath)
if !ok {
v.Body = execNodeTemplate("canvas-missing", subpath)
return
}
v.Markdown = true
v.Body = template.HTML(convertMarkdown(part, base))
}
// canvasSubpath narrows markdown to a file node's "#Heading" subpath: that
// heading's section including its subsections, matched case-insensitively
// on the heading text. Without a subpath it is the whole file; ok is false
// for a subpath that names no heading, which includes block references
// ("#^id").
func canvasSubpath(raw []byte, subpath string) (part []byte, ok bool) {
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
if want == "" {
return raw, true
}
sections := splitSections(raw)
for i, s := range sections {
if _, text := sectionHeading(s); i > 0 && strings.EqualFold(text, want) {
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
}
}
return nil, false
}
// canvasTaskCheckboxes makes a text node's task checkboxes clickable. The
// canvas editor (canvas.js) flips the Nth task line of the node's text and
// saves the whole canvas, so they carry only their index.
func canvasTaskCheckboxes(in []byte) []byte {
idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
out := `<input type="checkbox" class="task-checkbox" data-task="` + strconv.Itoa(idx) + `"`
if bytes.Contains(match, []byte("checked")) {
out += ` checked=""`
}
idx++
return []byte(out + ">")
})
}
func execNodeTemplate(name string, data any) template.HTML {
var buf bytes.Buffer
if err := canvasTmpl.ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("canvas template %s: %v", name, err)
}
return template.HTML(buf.String())
}
// === Handlers ===
type canvasPageData struct {
Title string
EditMode bool
CanEdit bool
IsRoot bool
PageURL string // the page the canvas belongs to
RawURL string
Name string
Nodes []canvasNodeView
Doc json.RawMessage
Version string
Error string
renderTimer
}
// serveCanvas handles every request to an existing .canvas file.
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
q := r.URL.Query()
switch {
case r.Method == http.MethodGet && q.Has("raw"):
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.ServeFile(w, r, fsPath)
case r.Method == http.MethodGet:
h.serveCanvasPage(w, r, urlPath, fsPath)
case r.Method == http.MethodPost && q.Has("save"):
h.handleCanvasSave(w, r, fsPath)
case r.Method == http.MethodPost && q.Has("render"):
h.handleCanvasRender(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *handler) serveCanvasPage(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
raw, err := os.ReadFile(fsPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
name := path.Base(urlPath)
data := canvasPageData{
Title: strings.TrimSuffix(name, path.Ext(name)),
CanEdit: true,
PageURL: pageURL(path.Dir(urlPath)),
RawURL: fileURL(strings.TrimPrefix(urlPath, "/")) + "?raw",
Name: name,
Version: canvasVersion(raw),
Doc: json.RawMessage(emptyCanvas),
}
doc, err := parseCanvas(raw)
if err != nil {
data.Error = err.Error()
} else {
if len(bytes.TrimSpace(raw)) > 0 {
data.Doc = json.RawMessage(raw)
}
for _, n := range doc.Nodes {
data.Nodes = append(data.Nodes, h.renderCanvasNode(n))
}
}
data.renderTimer = renderTimer{requestStart(r)}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("canvas template error: %v", err)
}
}
// maxCanvasBytes caps a saved canvas; text nodes hold markdown, not files.
const maxCanvasBytes = 10 << 20
// handleCanvasSave replaces the canvas with the posted document. The client
// sends the version it last loaded or saved (X-Canvas-Version); when the file
// has changed since, the save is refused with 409 so nothing is overwritten.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, fsPath string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCanvasBytes))
if err != nil {
http.Error(w, "canvas too large", http.StatusRequestEntityTooLarge)
return
}
if len(bytes.TrimSpace(body)) == 0 {
http.Error(w, "refusing to save an empty canvas body", http.StatusBadRequest)
return
}
if _, err := parseCanvas(body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var out bytes.Buffer
if err := json.Indent(&out, body, "", "\t"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
out.WriteByte('\n')
canvasMu.Lock()
defer canvasMu.Unlock()
current, err := os.ReadFile(fsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "this canvas no longer exists", http.StatusNotFound)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("X-Canvas-Version") != canvasVersion(current) {
http.Error(w, "the canvas changed since it was opened — reload to see the current version", http.StatusConflict)
return
}
if err := writeFileAtomic(fsPath, out.Bytes(), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("X-Canvas-Version", canvasVersion(out.Bytes()))
w.WriteHeader(http.StatusNoContent)
}
// handleCanvasRender renders one posted node as its element, so the editor
// shows nodes exactly as the server renders them on load.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
var n canvasNode
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCanvasBytes)).Decode(&n); err != nil {
http.Error(w, "bad node: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := canvasTmpl.ExecuteTemplate(w, "canvas-node", h.renderCanvasNode(n)); err != nil {
log.Printf("canvas template error: %v", err)
}
}
// canvasFileName validates a new canvas's name from the create dialog and
// returns the file name, with the extension added when missing.
func canvasFileName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if isCanvasFile(name) {
name = strings.TrimSpace(name[:len(name)-len(canvasExt)])
}
if name == "" {
return "", fmt.Errorf("name is empty")
}
if strings.HasPrefix(name, ".") || strings.ContainsAny(name, "/\\\x00") {
return "", fmt.Errorf("name must not start with a dot or contain slashes")
}
return name + canvasExt, nil
}
// handleCanvasCreate creates an empty canvas in the page's folder (creating
// the folder for a page that does not exist yet) and opens it.
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name, err := canvasFileName(r.FormValue("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, statErr := os.Stat(fsPath)
newlyCreated := os.IsNotExist(statErr)
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
f, err := os.OpenFile(filepath.Join(fsPath, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if errors.Is(err, os.ErrExist) {
http.Error(w, name+" already exists", http.StatusConflict)
return
}
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
return
}
_, werr := f.Write(emptyCanvas)
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr != nil {
http.Error(w, "write failed: "+werr.Error(), http.StatusInternalServerError)
return
}
if newlyCreated {
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
folderIndexAdd(filepath.ToSlash(rel))
}
}
rel := strings.Trim(urlPath, "/")
if rel != "" {
rel += "/"
}
redirectAfter(w, r, fileURL(rel+name))
}
+286
View File
@@ -0,0 +1,286 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func canvasRequest(h *handler, method, target, body string, header map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, strings.NewReader(body))
for k, v := range header {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestParseCanvas(t *testing.T) {
ok := []string{
``,
`{}`,
`{"nodes":[{"id":"a","type":"text","x":0,"y":0,"width":10,"height":10,"text":"hi","color":"3"},
{"id":"b","type":"group","x":0,"y":0,"width":10,"height":10,"color":"#ff8800"}],
"edges":[{"id":"e","fromNode":"a","toNode":"b","fromSide":"left","toEnd":"none"}]}`,
}
for _, src := range ok {
if _, err := parseCanvas([]byte(src)); err != nil {
t.Errorf("parseCanvas(%s): %v", src, err)
}
}
bad := map[string]string{
"not an object": `[]`,
"syntax": `{"nodes":[`,
"no id": `{"nodes":[{"type":"text","x":0,"y":0,"width":1,"height":1}]}`,
"duplicate id": `{"nodes":[{"id":"a","type":"text"},{"id":"a","type":"text"}]}`,
"unknown type": `{"nodes":[{"id":"a","type":"blob"}]}`,
"file w/o path": `{"nodes":[{"id":"a","type":"file"}]}`,
"link w/o url": `{"nodes":[{"id":"a","type":"link"}]}`,
"bad color": `{"nodes":[{"id":"a","type":"text","color":"red;x"}]}`,
"string x": `{"nodes":[{"id":"a","type":"text","x":"1"}]}`,
"dangling edge": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"zz"}]}`,
"bad side": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toSide":"up"}]}`,
"bad end": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toEnd":"dot"}]}`,
}
for name, src := range bad {
if _, err := parseCanvas([]byte(src)); err == nil {
t.Errorf("%s: parseCanvas accepted %s", name, src)
}
}
}
func TestCanvasSave(t *testing.T) {
initMarkdown()
root := t.TempDir()
file := filepath.Join(root, "Page", "Board.canvas")
writeTestFile(t, file, string(emptyCanvas))
h := &handler{root: root}
save := func(body, version string) *httptest.ResponseRecorder {
return canvasRequest(h, "POST", "/Page/Board.canvas?save", body, map[string]string{"X-Canvas-Version": version})
}
// Fields the server does not know survive the round trip.
doc := `{"nodes":[{"id":"a","type":"text","x":1,"y":2,"width":3,"height":4,"text":"hi","custom":{"k":1}}],"edges":[],"extra":true}`
w := save(doc, canvasVersion(emptyCanvas))
if w.Code != http.StatusNoContent {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
saved, _ := os.ReadFile(file)
var got map[string]any
if err := json.Unmarshal(saved, &got); err != nil {
t.Fatal(err)
}
if got["extra"] != true || !strings.Contains(string(saved), `"custom"`) {
t.Errorf("unknown fields lost:\n%s", saved)
}
if v := w.Header().Get("X-Canvas-Version"); v != canvasVersion(saved) {
t.Errorf("returned version %q does not match the file", v)
}
// A save based on an older version is refused and changes nothing.
if w := save(`{"nodes":[],"edges":[]}`, canvasVersion(emptyCanvas)); w.Code != http.StatusConflict {
t.Errorf("stale save: got %d, want 409", w.Code)
}
if now, _ := os.ReadFile(file); string(now) != string(saved) {
t.Error("stale save changed the file")
}
// Invalid documents are refused.
if w := save(`{"nodes":[{"id":"a","type":"nope"}]}`, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("invalid save: got %d, want 400", w.Code)
}
if w := save(``, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("empty save: got %d, want 400", w.Code)
}
// A canvas deleted while open is not recreated as a page.
os.Remove(file)
if w := save(doc, canvasVersion(saved)); w.Code != http.StatusNotFound {
t.Errorf("save to deleted canvas: got %d, want 404", w.Code)
}
if _, err := os.Stat(filepath.Join(root, "Page", "Board.canvas")); !os.IsNotExist(err) {
t.Error("save to deleted canvas created something")
}
}
func TestCanvasPage(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Page", "Board.canvas"),
`{"nodes":[{"id":"n1","type":"text","x":10,"y":20,"width":200,"height":100,"text":"# Hello\n\n- [ ] task","color":"2"}],"edges":[]}`)
writeTestFile(t, filepath.Join(root, "Page", "Broken.canvas"), `{"nodes":[`)
h := &handler{root: root}
w := canvasRequest(h, "GET", "/Page/Board.canvas", "", nil)
body := w.Body.String()
for _, want := range []string{
`class="canvas-app"`,
`data-id="n1"`,
`data-color="2"`,
`left: 10px; top: 20px; width: 200px; height: 100px;`,
`<h1 id="hello">Hello</h1>`,
`class="task-checkbox" data-task="0"`,
`id="canvas-data"`,
} {
if !strings.Contains(body, want) {
t.Errorf("canvas page lacks %q", want)
}
}
for _, unwanted := range []string{`tree-sidebar`, `hx-boost="true"`, `<footer`, `hx-post="?toggle`} {
if strings.Contains(body, unwanted) {
t.Errorf("canvas page contains %q", unwanted)
}
}
if w := canvasRequest(h, "GET", "/Page/Board.canvas?raw", "", nil); !strings.Contains(w.Body.String(), `"n1"`) ||
!strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
t.Errorf("?raw: %s %s", w.Header().Get("Content-Type"), w.Body)
}
w = canvasRequest(h, "GET", "/Page/Broken.canvas", "", nil)
if body := w.Body.String(); !strings.Contains(body, "data-readonly") || !strings.Contains(body, "cannot be edited") {
t.Errorf("broken canvas is not read-only:\n%s", body)
}
}
func TestCanvasRenderNodes(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Other Page", "index.md"), "# Other\n\n![shot](<pic one.jpg>)\n\n## Part\n\nPart text\n\n## Rest\n\nRest text\n")
writeTestFile(t, filepath.Join(root, "Other Page", "notes.md"), "[rel](sub/x.md) [abs](/y/) [web](https://e.x)\n")
writeTestFile(t, filepath.Join(root, "Other Page", "pic one.jpg"), "not really")
writeTestFile(t, filepath.Join(root, "Other Page", "data.zip"), "zip")
writeTestFile(t, filepath.Join(root, ".secret", "x.md"), "secret")
writeTestFile(t, filepath.Join(root, "Board.canvas"), "")
h := &handler{root: root}
render := func(node string) string {
w := canvasRequest(h, "POST", "/Board.canvas?render", node, nil)
if w.Code != 200 {
t.Fatalf("render %s: %d %s", node, w.Code, w.Body)
}
return w.Body.String()
}
page := render(`{"id":"a","type":"file","file":"Other Page","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(page, `href="/Other%20Page/"`) || !strings.Contains(page, ">Other<") ||
!strings.Contains(page, `src="/Other%20Page/pic%20one.jpg?w=300"`) {
t.Errorf("page node:\n%s", page)
}
part := render(`{"id":"a","type":"file","file":"/Other Page/index.md","subpath":"#Part","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(part, "Part text") || strings.Contains(part, "Rest text") || strings.Contains(part, "shot") {
t.Errorf("subpath node:\n%s", part)
}
notes := render(`{"id":"a","type":"file","file":"Other Page/notes.md","x":0,"y":0,"width":1,"height":1}`)
for _, want := range []string{`href="/Other%20Page/sub/x.md"`, `href="/y/"`, `href="https://e.x"`} {
if !strings.Contains(notes, want) {
t.Errorf("markdown file node lacks %s:\n%s", want, notes)
}
}
if img := render(`{"id":"a","type":"file","file":"Other Page/pic one.jpg","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(img, `<img src="/Other%20Page/pic%20one.jpg?w=800"`) {
t.Errorf("image node:\n%s", img)
}
if zip := render(`{"id":"a","type":"file","file":"Other Page/data.zip","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(zip, `class="canvas-card" href="/Other%20Page/data.zip"`) {
t.Errorf("file card node:\n%s", zip)
}
for _, p := range []string{"nope.md", ".secret/x.md", "../../etc/passwd"} {
if out := render(`{"id":"a","type":"file","file":"` + p + `","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(out, "canvas-missing") || strings.Contains(out, "secret") && p != ".secret/x.md" {
t.Errorf("file %q not reported missing:\n%s", p, out)
}
}
link := render(`{"id":"a","type":"link","url":"javascript:alert(1)","x":0,"y":0,"width":1,"height":1}`)
if strings.Contains(link, `href="javascript:`) {
t.Errorf("unsafe link rendered:\n%s", link)
}
named := render(`{"id":"a","type":"link","url":"https://jsoncanvas.org/spec/1.0/","label":"The spec","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(named, `<span class="truncate">The spec</span><span class="muted truncate">jsoncanvas.org</span>`) || !strings.Contains(named, `title="https://jsoncanvas.org/spec/1.0/"`) {
t.Errorf("link with display text:\n%s", named)
}
group := render(`{"id":"a","type":"group","label":"<b>G</b>","color":"#abc","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(group, "&lt;b&gt;G&lt;/b&gt;") || !strings.Contains(group, "--node-color: #abc") {
t.Errorf("group node:\n%s", group)
}
}
func TestCanvasCreate(t *testing.T) {
root := t.TempDir()
h := &handler{root: root}
create := func(name string) *httptest.ResponseRecorder {
form := url.Values{"name": {name}}.Encode()
return canvasRequest(h, "POST", "/New%20Page/?canvas", form, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"HX-Request": "true",
})
}
w := create("My Board")
if w.Code != http.StatusNoContent || w.Header().Get("HX-Redirect") != "/New%20Page/My%20Board.canvas" {
t.Fatalf("create: %d %q %s", w.Code, w.Header().Get("HX-Redirect"), w.Body)
}
raw, err := os.ReadFile(filepath.Join(root, "New Page", "My Board.canvas"))
if err != nil || string(raw) != string(emptyCanvas) {
t.Fatalf("created file: %v %q", err, raw)
}
if w := create("My Board.canvas"); w.Code != http.StatusConflict {
t.Errorf("duplicate: got %d, want 409", w.Code)
}
for _, bad := range []string{"", " ", ".hidden", "a/b", `a\b`} {
if w := create(bad); w.Code != http.StatusBadRequest {
t.Errorf("name %q: got %d, want 400", bad, w.Code)
}
}
}
func TestCanvasListedWithIcon(t *testing.T) {
if fileIcon("Board.canvas") != iconCanvas {
t.Error("canvas files do not get the canvas icon")
}
}
func TestCanvasSubpath(t *testing.T) {
doc := []byte("# LAN Party 2026\nIntro.\n## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n")
cases := []struct {
subpath, want string
ok bool
}{
{"", string(doc), true},
// The last section of the file runs to its end.
{"#teilnehmer", "## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n", true},
// A heading takes the sections nested under it along.
{"#LAN Party 2026", string(doc), true},
{"#Gäste", "", false},
{"#^block", "", false},
}
for _, c := range cases {
got, ok := canvasSubpath(doc, c.subpath)
if ok != c.ok || string(got) != c.want {
t.Errorf("canvasSubpath(%q) = %q, %v; want %q, %v", c.subpath, got, ok, c.want, c.ok)
}
}
}
func TestSectionSpanEndAtLastSection(t *testing.T) {
sections := splitSections([]byte("# A\n## B\ntext\n"))
if end := secionSpanEnd(sections, 2); end != 3 {
t.Errorf("span of the last section ends at %d, want 3", end)
}
if end := secionSpanEnd(sections, 1); end != 3 {
t.Errorf("span of the top section ends at %d, want 3", end)
}
}
+86
View File
@@ -0,0 +1,86 @@
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 "canvas":
renderDialog(w, "canvas", nil)
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
}
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now()
@@ -533,6 +533,8 @@ type diarySection struct {
EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto
Events string // day's calendar events, comma-separated (level 3 only)
Weight string // day's weight from the weight calendar (level 3 only)
}
type diaryContentData struct {
@@ -623,6 +625,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL)
attachCalendarLines(out, year)
return renderDiaryContent(out)
}
@@ -746,6 +749,28 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
return out
}
// attachCalendarLines fills the events and weight lines of every day section
// (real or virtual) from the calendars' diary and weight roles.
func attachCalendarLines(sections []diarySection, year int) {
lines := calendarDayLines(
time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local),
time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.Local))
if len(lines) == 0 {
return
}
for i := range sections {
s := &sections[i]
if s.Level != 3 {
continue
}
if _, _, _, ok := parseISODate(s.ID); !ok {
continue
}
l := lines[s.ID[:10]]
s.Events, s.Weight = l.Events, l.Weight
}
}
// isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against.
+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);
});
+151
View File
@@ -0,0 +1,151 @@
// End-to-end coverage of the canvas view: creating a canvas from a page,
// notes, connections, moving, deleting and the autosave behind all of them.
// Like pages.spec.js, every test works in its own fresh top-level folder.
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) => {
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh page, optionally holding Board.canvas with doc.
function sandbox(doc) {
const root = "c" + randomWord(7);
mkdirSync(join(wiki, root), { recursive: true });
writeFileSync(join(wiki, root, "index.md"), "# Canvas host\n");
if (doc) writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(doc));
return root;
}
const readCanvas = (root, name = "Board.canvas") => JSON.parse(readFileSync(join(wiki, root, name), "utf8"));
const node = (page, id) => page.locator(`.canvas-node[data-id="${id}"]`);
function note(id, x, y, text) {
return { id, type: "text", x, y, width: 200, height: 80, text };
}
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();
}
test("creates a canvas from a page and writes a note", async ({ page }) => {
const root = sandbox();
await page.goto(`/${root}/`);
await pageAction(page, "New canvas on this page");
await page.locator(".modal").getByPlaceholder("Canvas name").fill("Ideas");
await page.locator(".modal").getByRole("button", { name: "CREATE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Ideas\\.canvas$`));
// The canvas takes the whole shell: no folder tree, no footer.
await expect(page.locator("aside.tree-sidebar")).toHaveCount(0);
await expect(page.locator("footer")).toHaveCount(0);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node-editor .cm-content")).toBeFocused();
await page.keyboard.type("Hello **world**");
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
await expect.poll(() => readCanvas(root, "Ideas.canvas").nodes.map((n) => n.text)).toEqual(["Hello **world**"]);
await expect(page.locator(".canvas-status")).toHaveText("Saved");
await page.reload();
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
// The page lists its canvas.
await page.goto(`/${root}/`);
await expect(page.locator("main").getByRole("link", { name: "Ideas.canvas" })).toBeVisible();
});
test("an empty new note disappears", async ({ page }) => {
const root = sandbox({ nodes: [], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node")).toHaveCount(0);
});
test("edits a note with the page editor's toolbar", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "plain")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").dblclick();
await expect(page.locator(".canvas-node-toolbar")).toBeVisible();
await page.keyboard.press("Control+End");
await page.keyboard.type(" ");
await page.locator(".canvas-node-toolbar").getByTitle("Bold (B)").click();
await page.keyboard.type("strong");
// Clicking the background closes the editor.
await page.locator(".canvas-viewport").click({ position: { x: 5, y: 300 } });
await expect(page.locator(".canvas-node-toolbar")).toBeHidden();
await expect(node(page, "a").locator("strong")).toHaveText("strong");
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("plain **strong**");
});
test("connects two nodes by dragging a handle", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").click();
const handle = node(page, "a").locator('.canvas-handle[data-side="right"]');
const from = await handle.boundingBox();
const to = await node(page, "b").boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 5 });
await page.mouse.up();
await expect(page.locator(".canvas-edge")).toHaveCount(1);
await expect
.poll(() => readCanvas(root).edges.map((e) => [e.fromNode, e.fromSide, e.toNode, e.toSide]))
.toEqual([["a", "right", "b", "left"]]);
});
test("moves, undoes and deletes nodes", async ({ page }) => {
const root = sandbox({
nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")],
edges: [{ id: "e", fromNode: "a", toNode: "b" }],
});
await page.goto(`/${root}/Board.canvas`);
const box = await node(page, "a").boundingBox();
await page.mouse.move(box.x + 50, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + 50, box.y + 240, { steps: 5 });
await page.mouse.up();
await expect.poll(() => readCanvas(root).nodes[0].y).toBeGreaterThan(100);
await page.keyboard.press("Control+z");
await expect.poll(() => readCanvas(root).nodes[0].y).toBe(0);
// Deleting a node removes its connections too.
await node(page, "a").click();
await page.keyboard.press("Delete");
await expect(node(page, "a")).toHaveCount(0);
await expect.poll(() => readCanvas(root)).toMatchObject({ nodes: [{ id: "b" }], edges: [] });
});
test("a task checkbox in a note persists", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "- [ ] one\n- [ ] two")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").locator("input.task-checkbox").nth(1).check();
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("- [ ] one\n- [x] two");
});
test("a change made elsewhere stops the autosave", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
const other = { nodes: [note("z", 0, 0, "from elsewhere")], edges: [] };
writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(other));
await node(page, "a").click();
await page.keyboard.press("ArrowDown");
await expect(page.locator(".canvas-status")).toContainText("Changed elsewhere");
expect(readCanvas(root)).toEqual(other);
});
+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`);
});
+91 -159
View File
@@ -3,7 +3,7 @@ package main
import (
"bytes"
"path"
"regexp"
"strconv"
"strings"
"github.com/yuin/goldmark"
@@ -14,105 +14,95 @@ import (
"github.com/yuin/goldmark/util"
)
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// The inner blob forbids brackets and newlines but allows ':' so the whole
// target::caption::align payload travels as one capture — the caption and
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
//
// Destinations are relative to the page they sit on. Pages always render at a
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// resolves the destination and nothing here builds a URL or touches the
// filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
)
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
type wikiEmbedNode struct {
ast.BaseInline
Target string
Caption string
Align string
}
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
func (n *wikiEmbedNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": n.Target,
"Caption": n.Caption,
"Align": n.Align,
}, nil)
}
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
func alignKeyword(s string) (string, bool) {
switch strings.TrimSpace(s) {
// embedAlign maps an image title to its float class. A title that is not an
// alignment keyword is ignored and the embed floats right.
func embedAlign(title []byte) string {
switch strings.TrimSpace(string(title)) {
case alignLeft:
return alignLeft, true
case alignRight:
return alignRight, true
return alignLeft
case alignCenter:
return alignCenter, true
return alignCenter
}
return "", false
return alignRight
}
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// alignment per the embed syntax rules. The target is the field before the
// first "::". The trailing "::"-fields are interpreted as:
// - none: no caption, default alignment (right)
// - one keyword field: that alignment, no caption
// - one non-keyword field: that caption, default alignment
// - two or more: the last field is the alignment slot (default when it is not
// a keyword) and the earlier fields re-join with "::" as the caption, so a
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) {
fields := strings.Split(inner, "::")
target = strings.TrimSpace(fields[0])
tail := fields[1:]
align = alignRight
switch len(tail) {
case 0:
// target only
case 1:
if kw, ok := alignKeyword(tail[0]); ok {
align = kw
// embedIsImage reports whether a destination names an image, i.e. whether it
// renders as a <figure> rather than degrading to a link. Extension-only — no
// stat, so a destination gone stale simply 404s when clicked.
func embedIsImage(dest []byte) bool {
return isImageFile(path.Base(string(dest)))
}
type embedRenderer struct{}
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, r.render)
}
// render writes a <figure> holding a thumbnail that links to the full file,
// with the image's inline children as <figcaption>. Children are rendered by
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// emphasis or a link. A non-image destination renders as a plain anchor
// labelled with the caption, or with the file name when there is none.
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Image)
name := path.Base(string(n.Destination))
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
captioned := n.FirstChild() != nil
if !embedIsImage(n.Destination) {
if entering {
w.WriteString(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else {
caption = strings.TrimSpace(tail[0])
w.WriteString(`</a>`)
}
default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
return ast.WalkContinue, nil
}
if entering {
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title))
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest)
w.WriteString(`"><img src="`)
w.Write(dest)
w.WriteString(`?w=`)
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`" loading="lazy"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
return ast.WalkContinue, nil
}
return target, caption, align
}
type wikiEmbedParser struct{}
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
// Require the exact "![[" opener; a bare '!' (or a Markdown image "![](…)")
// falls through to goldmark's default image parser at priority 200.
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
return nil
if captioned {
w.WriteString(`</figcaption>`)
}
m := wikiEmbedRe.FindSubmatchIndex(line)
if m == nil {
return nil
}
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
if !isValidWikiTarget([]byte(target)) {
return nil
}
block.Advance(m[1])
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
@@ -122,7 +112,7 @@ func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Co
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{ root string }
type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
@@ -144,7 +134,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*wikiEmbedNode); ok {
if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
c = next
@@ -154,16 +144,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// embed and nothing else visible — only such embeds and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// that degrades to an inline link (missing / non-image target), stays wrapped so
// the fallback anchor keeps its paragraph.
// image and nothing else visible — only such images and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
// image that degrades to an inline link (non-image destination), stays wrapped
// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *wikiEmbedNode:
if !embedIsImage(t.root, n.Target) {
case *ast.Image:
if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
return hasFigure
}
// embedIsImage reports whether target resolves to an existing image file, i.e.
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
type embedExt struct{}
type wikiEmbedRenderer struct {
root string
}
// newEmbedExt returns a goldmark extension that renders Markdown images as
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
// the renderer registered at 500 wins over the default HTML renderer
// (registered at 1000 — lowest priority number is applied last and wins).
func newEmbedExt() goldmark.Extender { return &embedExt{} }
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiEmbed, r.render)
}
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiEmbedNode)
// Embed only when the target exists and is an image we can thumbnail.
// Anything else — a missing target, or an existing non-image file — degrades
// to the same anchor a plain [[wikilink]] would render (broken or working).
if !embedIsImage(r.root, n.Target) {
writeWikiLink(w, r.root, n.Target, "")
return ast.WalkContinue, nil
}
fileHref := wikiFileHref(n.Target)
alt := n.Caption
if alt == "" {
alt = path.Base(normalizeWikiTarget(n.Target))
}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
type wikiEmbedExt struct{ root string }
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
// image embeds resolved against root.
func newWikiEmbedExt(root string) goldmark.Extender {
return &wikiEmbedExt{root: root}
}
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default image parser (200) so ![[...]]
// is consumed as an embed before the default `!` parser sees it.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiEmbedParser{}, 199),
))
func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
util.Prioritized(&embedRenderer{}, 500),
))
}
+230 -171
View File
@@ -2,14 +2,12 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"net/url"
"sort"
"strconv"
"strings"
@@ -20,10 +18,6 @@ func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
}
// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"
type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders
@@ -34,20 +28,48 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request)
// handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so
// the user can verify an uploaded export arrived.
// the folder can still hold notes.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" {
return nil
}
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
weightView, weeklyView := fitnessViews(r.URL.Query())
return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
Content: renderFitnessDashboard(fsPath, weightView, weeklyView),
SuppressTOC: true,
}
}
// serveFragment answers a chart's controls (htmx GET ?chart=<param> with
// every chart's view state) with just that chart, and points the address bar
// at the canonical dashboard URL so the view stays bookmarkable.
func (f *fitnessHandler) serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool {
q := r.URL.Query()
param := q.Get("chart")
if param == "" {
return false
}
if s := readPageSettings(fsPath); s == nil || s.Type != "fitness" {
return false
}
weightView, weeklyView := fitnessViews(q)
dash := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
for _, c := range dash.Charts {
if c.Param != param {
continue
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Replace-Url", fitnessURL(urlPath, weightView, weeklyView))
if err := fitnessTmpl.ExecuteTemplate(w, "fitnessChart", c); err != nil {
log.Printf("fitness template: %v", err)
}
return true
}
http.Error(w, "Unknown chart: "+param, http.StatusNotFound)
return true
}
// === Time ranges ===
type fitnessRange struct {
@@ -72,6 +94,50 @@ func validFitnessRange(v, fallback string) string {
return fallback
}
// fitnessView is one chart's view state from the query string: ?<param>=<range>
// and ?<param>-absolute=1 to start the y axis at 0.
type fitnessView struct {
Range string
Absolute bool
}
func chartViewFrom(q url.Values, param, fallback string) fitnessView {
return fitnessView{
Range: validFitnessRange(q.Get(param), fallback),
Absolute: q.Get(param+"-absolute") == "1",
}
}
// Default ranges of the two charts.
const (
weightDefaultRange = "3m"
weeklyDefaultRange = "1y"
)
func fitnessViews(q url.Values) (weight, weekly fitnessView) {
return chartViewFrom(q, "weight", weightDefaultRange), chartViewFrom(q, "weekly", weeklyDefaultRange)
}
// fitnessURL is the dashboard URL for the given views; defaults are left out.
func fitnessURL(urlPath string, weight, weekly fitnessView) string {
q := url.Values{}
add := func(param, fallback string, v fitnessView) {
if v.Range != fallback {
q.Set(param, v.Range)
}
if v.Absolute {
q.Set(param+"-absolute", "1")
}
}
add("weight", weightDefaultRange, weight)
add("weekly", weeklyDefaultRange, weekly)
target := pageURL(urlPath)
if len(q) > 0 {
target += "?" + q.Encode()
}
return target
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
@@ -81,95 +147,6 @@ func rangeMonths(v string) int {
return 0
}
// === Waistline export parsing ===
// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
val float64
ok bool
}
func (n *wlNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
n.val = v
n.ok = true
return nil
}
// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
if len(s) < 10 {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return time.Time{}, false
}
return t, true
}
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
var s struct {
Goals map[string]json.RawMessage `json:"goals"`
}
if json.Unmarshal(settings, &s) != nil {
return 0, false
}
var g struct {
GoalList []struct {
Goal []wlNum `json:"goal"`
} `json:"goal-list"`
}
if json.Unmarshal(s.Goals[key], &g) != nil {
return 0, false
}
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
return 0, false
}
n := g.GoalList[0].Goal[0]
return n.val, n.ok
}
func exportWeightUnit(settings json.RawMessage) string {
var s struct {
Units struct {
Weight string `json:"weight"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
return s.Units.Weight
}
return "kg"
}
// === Series extraction ===
type weightPoint struct {
@@ -178,26 +155,34 @@ type weightPoint struct {
days int // >0: weekly mean over this many measured days
}
// extractWeights computes the weight series from the export: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
// one wins.
func extractWeights(ex *wlExport) []weightPoint {
byDate := map[time.Time]float64{}
for _, e := range ex.Diary {
day, ok := exportDate(e.DateTime)
if !ok || !e.Stats.Weight.ok {
// fitnessWeights reads the whole weight calendar up to today: one point per
// day, sorted by date. Dates are UTC midnights to match the chart's today.
func fitnessWeights(today time.Time) []weightPoint {
byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
weights := make([]weightPoint, 0, len(byDay))
for key, w := range byDay {
d, err := time.Parse("2006-01-02", key)
v, err2 := strconv.ParseFloat(w, 64)
if err != nil || err2 != nil {
continue
}
byDate[day] = e.Stats.Weight.val
}
weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate {
weights = append(weights, weightPoint{date: d, value: v})
}
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights
}
// weightGoal reads the optional weight-goal key from the fitness folder's
// .page-settings.
func weightGoal(fsPath string) (float64, bool) {
v, ok := readSettingsFile(fsPath)["weight-goal"]
if !ok {
return 0, false
}
g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64)
return g, err == nil
}
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
@@ -280,8 +265,9 @@ type fitnessTickVM struct {
Pos, Label, Anchor string
}
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
type fitnessDotVM struct {
X, Y, Title string
X, Y, Date, Value string
}
type fitnessGoalVM struct {
@@ -289,10 +275,11 @@ type fitnessGoalVM struct {
}
type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Empty bool
Title string
Param string
Options []fitnessOptVM
Absolute bool
Empty bool
ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string
@@ -301,6 +288,7 @@ type fitnessChartVM struct {
Goal *fitnessGoalVM
Lines []string // polyline points attributes
Dots []fitnessDotVM
Dense bool // too many points to draw markers
}
type fitnessDashVM struct {
@@ -308,13 +296,13 @@ type fitnessDashVM struct {
Charts []fitnessChartVM
}
func newChartVM(title, param, sel string) fitnessChartVM {
func newChartVM(title, param string, view fitnessView) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range}
}
return fitnessChartVM{
Title: title, Param: param, Options: opts,
Title: title, Param: param, Options: opts, Absolute: view.Absolute,
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
@@ -323,7 +311,7 @@ func newChartVM(title, param, sel string) fitnessChartVM {
}
// yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
// (5 kg guides on weight charts, 20 kg when the axis starts at 0).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
@@ -346,12 +334,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
// buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel)
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, view)
points := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
if m := rangeMonths(view.Range); m > 0 {
x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0)
if len(points) == 0 {
@@ -391,21 +379,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
pad = 1
}
sc := chartScale{x0, x1, lo - pad, hi + pad}
yStep := 5.0
if view.Absolute {
sc.y0 = 0
sc.y1 = hi + hi*0.05
yStep = 20
}
vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc, 4)
vm.YTicks = yTickVMs(sc, yStep)
vm.XTicks = timeXTicks(sc)
// One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover
// <title>; on dense ranges they are dropped and the bare line stays
// legible. A single point in range renders as a dot.
// measurement do not break the line. Every point also gets a marker that
// feeds the hover tooltip; on dense ranges the markers are hidden and the
// bare line stays legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
if p.days > 0 {
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
d := fitnessDotVM{
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
Date: p.date.Format("Mon 2006-01-02"),
Value: svgNum(p.value) + " " + unit,
}
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
if p.days > 0 {
d.Date = "Week of " + p.date.Format("2006-01-02")
d.Value += fmt.Sprintf(" · %d days", p.days)
}
return d
}
if len(points) >= 2 {
var b strings.Builder
@@ -419,11 +417,10 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
}
vm.Lines = append(vm.Lines, b.String())
}
if len(points) <= 100 {
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
vm.Dense = len(points) > 100
if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -441,34 +438,101 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
return out
}
// timeXTicks places n+1 evenly spaced date labels across a continuous time
// axis; the last label is end-anchored so it stays inside the viewBox.
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
span := sc.x1.Sub(sc.x0)
var out []fitnessTickVM
prev := ""
for i := 0; i <= n; i++ {
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
label := t.Format("2006-01-02")
if label == prev {
continue
// xTickStep is a calendar-aligned spacing for date labels.
type xTickStep struct {
days, months int
label func(t time.Time, first bool) string
}
var xTickSteps = []xTickStep{
{days: 1, label: dayLabel},
{days: 7, label: dayLabel},
{days: 14, label: dayLabel},
{months: 1, label: monthLabel},
{months: 3, label: monthLabel},
{months: 6, label: monthLabel},
{months: 12, label: yearLabel},
{months: 24, label: yearLabel},
{months: 60, label: yearLabel},
}
func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") }
// monthLabel names the year only where it changes, so a year-long axis
// reads "Oct 2025, Jan 2026, Apr, Jul".
func monthLabel(t time.Time, first bool) string {
if first || t.Month() == time.January {
return t.Format("Jan 2006")
}
return t.Format("Jan")
}
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
// maxXTicks bounds the label count so labels never collide on the axis.
const maxXTicks = 5
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
// month starts or new years, whichever is the finest step that stays within
// maxXTicks. Labels near the plot edges are anchored inward so they stay
// inside the viewBox.
func timeXTicks(sc chartScale) []fitnessTickVM {
var ticks []time.Time
var step xTickStep
for _, step = range xTickSteps {
ticks = alignedTicks(sc.x0, sc.x1, step)
if len(ticks) <= maxXTicks {
break
}
prev = label
}
out := make([]fitnessTickVM, 0, len(ticks))
for i, t := range ticks {
x := sc.x(t)
anchor := "middle"
if i == n {
switch {
case x-chartLeft < 24:
anchor = "start"
case chartW-chartRight-x < 24:
anchor = "end"
}
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor})
}
return out
}
// alignedTicks lists every boundary of step within [from, to]. Week steps
// start on Mondays; month and year steps on multiples of the step (Jan/Apr/
// Jul/Oct for quarters, even years for two-year steps).
func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
var t time.Time
if step.days > 0 {
t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
if step.days > 1 {
t = mondayOf(t)
}
} else {
months := from.Year()*12 + int(from.Month()) - 1
months -= months % step.months
t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC)
}
var out []time.Time
for ; !t.After(to); t = t.AddDate(0, step.months, step.days) {
if !t.Before(from) {
out = append(out, t)
}
if len(out) > maxXTicks {
break
}
}
return out
}
// === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err)
@@ -477,28 +541,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
return template.HTML(buf.String())
}
// buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
if err != nil {
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
}
var ex wlExport
if err := json.Unmarshal(raw, &ex); err != nil {
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
// buildFitnessDash reads the weight calendar per request (the calendar store
// caches parsed files). A missing calendar setup becomes an inline notice;
// the page itself always renders.
func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex)
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
unit := exportWeightUnit(ex.Settings)
weights := fitnessWeights(today)
wGoal, hasWGoal := weightGoal(fsPath)
const unit = "kg"
return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
buildWeightChart(weights, wGoal, hasWGoal, weightView,
"weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
"weekly", "Weekly average weight ("+unit+")", unit, today),
}}
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"net/http/httptest"
"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)
}
}
}
func TestFitnessChartFragment(t *testing.T) {
withBerlin(t)
root := t.TempDir()
cals := filepath.Join(root, "Calendars")
fit := filepath.Join(root, "My Fitness")
for _, d := range []string{cals, fit} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
prev := calendarsDir
calendarsDir = cals
t.Cleanup(func() { calendarsDir = prev })
if err := os.WriteFile(filepath.Join(cals, ".page-settings"), []byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fit, ".page-settings"), []byte("type = fitness\n"), 0644); err != nil {
t.Fatal(err)
}
writeCal(t, cals, "Gewicht", `UID:1
SUMMARY:96 kg
DTSTART;VALUE=DATE:20200101`)
h := &handler{root: root}
get := func(query string, hx bool) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/My%20Fitness/?"+query, nil)
if hx {
r.Header.Set("HX-Request", "true")
}
w := httptest.NewRecorder()
h.serveDir(w, r, "/My Fitness", fit)
return w
}
w := get("chart=weekly&weight=3m&weekly=all&weekly-absolute=1", true)
body := w.Body.String()
if w.Code != 200 || strings.Contains(body, "<html") || strings.Count(body, `class="fitness-chart panel"`) != 1 ||
!strings.Contains(body, `name="weekly"`) || !strings.Contains(body, `value="all" selected`) {
t.Errorf("weekly fragment: %d\n%s", w.Code, body)
}
if got, want := w.Header().Get("HX-Replace-Url"), "/My%20Fitness/?weekly=all&weekly-absolute=1"; got != want {
t.Errorf("HX-Replace-Url = %q, want %q", got, want)
}
if w := get("chart=bogus", true); w.Code != 404 {
t.Errorf("unknown chart: got %d, want 404", w.Code)
}
// Without htmx the parameter is ignored and the full page renders.
if body := get("chart=weekly", false).Body.String(); strings.Count(body, `class="fitness-chart panel"`) != 2 {
t.Errorf("non-htmx request did not render the dashboard:\n%s", body)
}
}
+5 -1
View File
@@ -2,4 +2,8 @@ module datascape
go 1.26.1
require github.com/yuin/goldmark v1.8.2
require (
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
github.com/teambition/rrule-go v1.8.2
github.com/yuin/goldmark v1.8.2
)
+4
View File
@@ -1,2 +1,6 @@
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+164 -56
View File
@@ -2,50 +2,37 @@ package main
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"flag"
"html/template"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
)
//go:embed assets
var assets embed.FS
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
// appended as ?v=… to its <script> src. The bundle is served immutable under a
// stable filename, so without this query a rebuilt bundle would never reach a
// client that already cached the old one (this is the editor cache-bust knob).
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
func hashAsset(name string) string {
b, err := assets.ReadFile(name)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:12]
}
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
// tmplFuncs is shared by every layout-based template: asset links versioned
// static files (see static.go); the layout header renders the maintenance
// widget.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
"asset": assetURL,
"fileIcon": fileIcon,
"icon": readIcon,
"maintenanceWidget": maintenanceWidget,
}
var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/toolbar.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
)
@@ -77,6 +64,14 @@ type pageTypeHandler interface {
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}
// fragmentHandler is optionally implemented by a pageTypeHandler whose page
// updates part of itself over htmx (e.g. one chart of a dashboard). It
// answers only htmx GETs outside the editor and returns false when the
// request is not for one of its fragments, so the full page renders.
type fragmentHandler interface {
serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool
}
// pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler
@@ -87,8 +82,18 @@ func main() {
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)")
tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)")
flag.Parse()
if *tz != "" {
loc, err := time.LoadLocation(*tz)
if err != nil {
log.Fatalf("-tz: %v", err)
}
time.Local = loc
}
root, err := filepath.Abs(*wikiDir)
if err != nil {
log.Fatal(err)
@@ -105,7 +110,16 @@ func main() {
log.Fatal(err)
}
initMarkdown(root)
initMarkdown()
if *calendars != "" {
calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars))
rel, err := filepath.Rel(root, calendarsDir)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
log.Fatalf("-calendars must be a folder inside -dir")
}
calendarsURL = "/" + filepath.ToSlash(rel) + "/"
}
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
@@ -113,14 +127,7 @@ func main() {
}
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
static.ServeHTTP(w, r)
}))
http.Handle("/_/", serveStatic())
http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest)
@@ -157,6 +164,10 @@ func main() {
}(*reindexInterval)
}
if calendarsDir != "" {
startCalendarSync()
}
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil))
}
@@ -172,14 +183,21 @@ type reqStartKeyT struct{}
var reqStartKey = reqStartKeyT{}
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
func elapsedMS(r *http.Request) int64 {
// requestStart returns when the request entered ServeHTTP, or now if unknown.
func requestStart(r *http.Request) time.Time {
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
return time.Since(start).Milliseconds()
return start
}
return 0
return time.Now()
}
// renderTimer is embedded in template data. The footer calls RenderMS while
// the template executes, so the time includes the page's own rendering (and
// the header's maintenance widget), not just the work before it.
type renderTimer struct{ start time.Time }
func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() }
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
@@ -187,6 +205,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Dotfiles are never served: .page-settings can hold the CalDAV
// password and .auth-key signs sessions. Nothing links to them (listing,
// tree and search hide them already).
if hasDotSegment(r.URL.Path) {
http.NotFound(w, r)
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r)
return
@@ -215,12 +241,18 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
// A canvas deleted or moved while open: don't let its autosave
// fall through to the page flow below.
if r.Method == http.MethodPost && isCanvasFile(urlPath) {
http.Error(w, "this canvas no longer exists", http.StatusNotFound)
return
}
// Non-existent path: redirect GETs to the canonical slash form so
// the browser URL is consistent, then serve an empty folder page.
// POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
h.serveDir(w, r, urlPath, fsPath)
@@ -232,14 +264,52 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() {
if urlPath != "/" {
// Pages must live at a slash-terminated URL: embed destinations are
// relative, so "/Trips/Japan" would resolve them against "/Trips/".
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
urlPath += "/"
}
h.serveDir(w, r, urlPath, fsPath)
} else if isCanvasFile(fsPath) {
h.serveCanvas(w, r, urlPath, fsPath)
} else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
// their <img src> can stay relative to the page; /_thumb/<abs path>
// stays for listings, which have absolute paths on hand anyway. A file
// with no thumbnailer ignores the parameter and serves normally.
if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
h.serveThumbnail(w, r, fsPath)
return
}
http.ServeFile(w, r, fsPath)
}
}
// hasDotSegment reports whether any segment of the cleaned URL path starts
// with a dot.
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(path.Clean("/"+p), "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve
// against.
func slashedURL(u *url.URL) string {
target := u.Path + "/"
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
return target
}
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"]
@@ -247,6 +317,18 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
h.handlePost(w, r, urlPath, fsPath)
return
}
if r.URL.Query().Has("dialog") {
h.serveDialog(w, r, urlPath, fsPath)
return
}
if isHX(r) && !editMode {
for _, ph := range pageTypeHandlers {
if fh, ok := ph.(fragmentHandler); ok && fh.serveFragment(w, r, h.root, fsPath, urlPath) {
return
}
}
}
for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
@@ -348,20 +430,21 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
Content: rendered,
Entries: entries,
View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent,
SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC,
}
if !editMode {
data.Tree = sidebarTree(h.root, urlPath)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t := pageTmpl
if editMode {
t = editTmpl
}
data.RenderMS = elapsedMS(r)
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
data.renderTimer = renderTimer{requestStart(r)}
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
log.Printf("template error: %v", err)
}
}
@@ -373,7 +456,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return
}
if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), 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
}
if query.Has("toggle") {
@@ -388,6 +482,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath)
return
}
if query.Has("canvas") {
h.handleCanvasCreate(w, r, urlPath, fsPath)
return
}
if query.Has("calsync") {
h.handleCalendarSync(w, r, urlPath, fsPath)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -499,12 +601,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
// Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings {
kv := readSettingsFile(dir)
if kv == nil {
return nil
}
// Unknown or missing values fall back to the defaults.
return &pageSettings{
Type: kv["type"],
View: validateView(kv["view"]),
Sort: validateSort(kv["sort"]),
Order: validateOrder(kv["order"]),
}
}
// readSettingsFile returns every key of dir's .page-settings (last one wins),
// or nil if the file does not exist. Other features (e.g. the calendars
// folder) keep their own keys in the same file.
func readSettingsFile(dir string) map[string]string {
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil {
return nil
}
// Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
kv := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -514,17 +632,7 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 {
continue
}
value := strings.TrimSpace(parts[1])
switch strings.TrimSpace(parts[0]) {
case "type":
s.Type = value
case "view":
s.View = validateView(value)
case "sort":
s.Sort = validateSort(value)
case "order":
s.Order = validateOrder(value)
}
kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return s
return kv
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"bytes"
"html/template"
"log"
"sort"
"time"
)
// calendarsURL is the page URL of calendarsDir ("/Calendars/").
var calendarsURL string
// maintenanceHorizon is how many days after today the header widget lists.
const maintenanceHorizon = 7
type maintItem struct {
When string // "03:00" today; "Sa 27.09." or "Sa 27.09. 03:00" later
Summary string
}
type maintWidgetData struct {
Count int // occurrences today (the badge)
Today []maintItem
Upcoming []maintItem
PageURL string
}
var maintWidgetTmpl = template.Must(template.New("widget.html").
Funcs(template.FuncMap{"icon": readIcon}).
ParseFS(assets, "assets/calendar/widget.html"))
// maintenanceWidget renders the header chip and its dropdown. It is a
// template func so layout.html can call it without every page's data struct
// carrying it. Empty when no maintenance calendar is configured.
func maintenanceWidget() template.HTML {
if calendarsDir == "" {
return ""
}
names := readCalendarSettings(calendarsDir).Maintenance
if len(names) == 0 {
return ""
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
tomorrow := today.AddDate(0, 0, 1)
data := maintWidgetData{PageURL: calendarsURL}
for _, ev := range calendarOccurrences(calendarsDir, names, today, today.AddDate(0, 0, maintenanceHorizon+1)) {
// Anything that started before tomorrow is due today, including
// multi-day events still running.
if ev.Start.Before(tomorrow) {
item := maintItem{Summary: ev.Summary}
if !ev.AllDay && !ev.Start.Before(today) {
item.When = ev.Start.Format("15:04")
}
data.Today = append(data.Today, item)
continue
}
when := calDayLabel(ev.Start)
if !ev.AllDay {
when += " " + ev.Start.Format("15:04")
}
data.Upcoming = append(data.Upcoming, maintItem{When: when, Summary: ev.Summary})
}
data.Count = len(data.Today)
var buf bytes.Buffer
if err := maintWidgetTmpl.Execute(&buf, data); err != nil {
log.Printf("maintenance widget template: %v", err)
return ""
}
return template.HTML(buf.String())
}
// maintSeriesRow is one task in the calendars page's maintenance table.
type maintSeriesRow struct {
Summary string
Rule string
Last string // most recent occurrence before today
Next string // first occurrence today or later
next time.Time
}
// maintenanceWindow bounds the table's occurrence search, in days each way
// from today. A year covers yearly tasks.
const maintenanceWindow = 366
// maintenanceSeries lists every task of the named calendars in dir: one row
// per recurring series with its last and next date, plus upcoming single
// events. Sorted by next date; series without a next date come last.
func maintenanceSeries(dir string, names []string, now time.Time) []maintSeriesRow {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
evs := calendarOccurrences(dir, names, today.AddDate(0, 0, -maintenanceWindow), today.AddDate(0, 0, maintenanceWindow))
type key struct{ calendar, uid string }
rows := map[key]*maintSeriesRow{}
var order []*maintSeriesRow
for _, ev := range evs {
if ev.Series == nil {
if ev.Start.Before(today) {
continue
}
order = append(order, &maintSeriesRow{
Summary: ev.Summary,
Rule: "once",
Next: calDayLabel(ev.Start),
next: ev.Start,
})
continue
}
k := key{ev.Calendar, ev.Series.UID}
row := rows[k]
if row == nil {
row = &maintSeriesRow{Summary: ev.Series.Summary, Rule: ev.Series.Rule}
rows[k] = row
order = append(order, row)
}
// Occurrences arrive sorted by start: keep overwriting Last, take
// the first one from today on as Next.
if ev.Start.Before(today) {
row.Last = calDayLabel(ev.Start)
} else if row.next.IsZero() {
row.next = ev.Start
row.Next = calDayLabel(ev.Start)
}
}
out := make([]maintSeriesRow, 0, len(order))
for _, r := range order {
out = append(out, *r)
}
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i].next, out[j].next
if a.IsZero() != b.IsZero() {
return b.IsZero()
}
if !a.Equal(b) {
return a.Before(b)
}
return out[i].Summary < out[j].Summary
})
return out
}
+13 -124
View File
@@ -1,27 +1,21 @@
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
// updateLinks is true it also rewrites every [[...]] wiki link across the
// tree that targets the old path or any descendant; rewritten files are held
// in memory for rollback.
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
//
// If the destination folder already exists it is normally a hard conflict.
// 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
// 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
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
// is true; otherwise the client is asked to confirm it.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL)
if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -56,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return
}
if !merge {
// Ask the client to confirm the merge. The header lets it tell
// this apart from an ordinary conflict.
w.Header().Set("X-Merge-Available", "1")
// Ask the user to confirm: htmx clients get the merge dialog as
// the next step; others a conflict they can retry with &merge=1.
if isHX(r) {
renderDialog(w, "merge", struct{ Dest string }{newPath})
return
}
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return
}
@@ -74,69 +71,32 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
merging = true
}
// Phase 1: optionally walk the tree and rewrite every index.md that
// references the moved path. Keep the pre-rewrite bytes in memory so we
// can revert on failure. The walker only reads directory listings and
// files literally named index.md; hidden directories are pruned. A cheap
// substring check skips parsing files that cannot contain a relevant
// link.
rewritten := map[string][]byte{}
if updateLinks {
needle := []byte("[[" + oldPath)
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
orig, err := os.ReadFile(fsPath)
if err != nil {
return err
}
if !bytes.Contains(orig, needle) {
return nil
}
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
if !changed {
return nil
}
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
return fmt.Errorf("write %s: %w", fsPath, err)
}
rewritten[fsPath] = orig
return nil
})
if walkErr != nil {
rollbackRewrites(rewritten)
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
return
}
}
// Phase 2: create intermediate parent folders for the destination.
// Phase 1: create intermediate parent folders for the destination.
if parent := filepath.Dir(dstFsPath); parent != "" {
if err := os.MkdirAll(parent, 0755); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Phase 3: move the source into place. A plain move renames the whole
// Phase 2: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing
// destination and drops the emptied source.
if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
}
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
redirectAfter(w, r, pageURL(newPath))
}
// canMergeMove reports whether moving onto an existing destination should
@@ -211,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
return
}
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,
@@ -248,77 +208,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
return "/" + trimmed, nil
}
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
// has its target rewritten to the corresponding position under newPath.
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
changed := false
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
parts := wikiLinkPattern.FindSubmatch(match)
if parts == nil {
return match
}
target := strings.TrimSpace(string(parts[1]))
normTarget := normalizeMovePath(target)
var newTarget string
switch {
case normTarget == oldPath:
newTarget = newPath
case strings.HasPrefix(normTarget, oldPath+"/"):
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
default:
return match
}
changed = true
suffix := ""
if len(parts[2]) > 0 {
suffix = "::" + string(parts[2])
}
return []byte("[[" + newTarget + suffix + "]]")
})
return out, changed
}
// rollbackRewrites restores the given files to their pre-rewrite contents.
// Errors are logged; best-effort since we're already in a failure path.
func rollbackRewrites(rewritten map[string][]byte) {
for path, orig := range rewritten {
if err := writeFileAtomic(path, orig, 0644); err != nil {
log.Printf("rollback %s: %v", path, err)
}
}
}
// walkIndexFiles visits every `index.md` under root, skipping hidden
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
// not stat each regular file — on spinning disks that saves the bulk of the
// traversal cost when folders contain many non-page files (photos, archives).
func walkIndexFiles(root string, visit func(fsPath string) error) error {
entries, err := os.ReadDir(root)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
full := filepath.Join(root, name)
if e.IsDir() {
if err := walkIndexFiles(full, visit); err != nil {
return err
}
continue
}
if name == "index.md" {
if err := visit(full); err != nil {
return err
}
}
}
return nil
}
// writeFileAtomic writes data to a temp file in the same directory as path
// and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+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)
return
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
redirectAfter(w, r, pageURL(urlPath))
}
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"time"
)
var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html"))
var quickAddTmpl = template.Must(template.New("quickadd.html").Funcs(assetFuncs).ParseFS(assets, "assets/quickadd.html"))
type quickAddData struct {
To, URL, Title string
+59 -13
View File
@@ -7,24 +7,27 @@ import (
"net/url"
"os"
"path"
"regexp"
"sort"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
)
var md goldmark.Markdown
// initMarkdown builds the package-level goldmark instance. Called once from
// main after the wiki root is known so the wiki-link extension can resolve
// targets against the filesystem.
func initMarkdown(root string) {
// initMarkdown builds the package-level goldmark instance. Neither extension
// resolves anything against the filesystem — wiki links are search queries and
// embed destinations are relative to the page — so this needs no wiki root.
func initMarkdown() {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
)
@@ -58,12 +61,11 @@ type pageData struct {
Content template.HTML
Entries []entry
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
SidebarWidget template.HTML
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
@@ -130,19 +132,61 @@ var (
iconAudio = readIcon("audio")
iconArchive = readIcon("archive")
iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
)
// renderMarkdown converts raw markdown to trusted HTML.
func renderMarkdown(raw []byte) template.HTML {
return template.HTML(rewriteTaskCheckboxes(convertMarkdown(raw, "")))
}
// convertMarkdown renders raw markdown to HTML with the wiki's table styling
// but leaves task checkboxes as goldmark emits them (disabled), so callers
// decide how they toggle.
//
// base, when set, is the URL path (slash-terminated) relative link and embed
// destinations resolve against. Pages leave it empty: the browser resolves
// them against the page URL. Markdown shown somewhere else (a canvas file
// node showing another folder's page) passes that folder.
func convertMarkdown(raw []byte, base string) []byte {
var buf bytes.Buffer
if err := md.Convert(raw, &buf); err != nil {
return ""
doc := md.Parser().Parse(text.NewReader(raw))
if base != "" {
rebaseDestinations(doc, base)
}
if err := md.Renderer().Render(&buf, raw, doc); err != nil {
return nil
}
out := rewriteTaskCheckboxes(buf.Bytes())
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
// .data-table styling with the grid modifier (per-cell borders + header).
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
return template.HTML(out)
return bytes.ReplaceAll(buf.Bytes(), []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
}
// schemeRe matches a URL that starts with a scheme ("https:", "mailto:").
var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
// rebaseDestinations prefixes every relative link and image destination in
// doc with base. Absolute paths, fragments, queries and URLs with a scheme
// stay as they are.
func rebaseDestinations(doc ast.Node, base string) {
rebase := func(dest []byte) []byte {
if len(dest) == 0 || dest[0] == '/' || dest[0] == '#' || dest[0] == '?' || schemeRe.Match(dest) {
return dest
}
return append([]byte(base), dest...)
}
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n := n.(type) {
case *ast.Link:
n.Destination = rebase(n.Destination)
case *ast.Image:
n.Destination = rebase(n.Destination)
}
return ast.WalkContinue, nil
})
}
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
@@ -302,6 +346,8 @@ func fileIcon(name string) template.HTML {
switch ext {
case ".md", ".pdf":
return iconDoc
case canvasExt:
return iconCanvas
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
return iconImage
case ".mp4", ".mkv", ".avi", ".mov":
+111 -13
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"html/template"
"io/fs"
"log"
"net/http"
@@ -12,6 +13,7 @@ import (
"sync"
"time"
"unicode"
"unicode/utf8"
)
// searchSectionCap bounds how many rows each results section renders. The
@@ -46,7 +48,8 @@ type searchPageData struct {
PageTotal int
FileTotal int
IndexBuiltAt time.Time
RenderMS int64
Tree template.HTML // sidebar folder tree
renderTimer
}
// 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),
FileTotal: len(files),
IndexBuiltAt: builtAt,
Tree: sidebarTree(h.root, "/"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
data.renderTimer = renderTimer{requestStart(r)}
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
log.Printf("search template error: %v", err)
}
}
@@ -278,7 +282,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 {
best = 20
}
case fuzzy && levenshtein(w, qt) <= 2:
case fuzzy && withinTwoEdits(w, qt):
if best < 5 {
best = 5
}
@@ -289,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
return score
}
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
// the editor's link picker. Caps results at 5; reports total so the UI can
// surface a "show all" footer when more matches exist. Empty/whitespace query
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
type suggestResult struct {
Name string `json:"name"`
Path string `json:"path"`
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) {
if !h.checkAuth(w, r) {
return
}
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 {
Query string `json:"query"`
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")
if err := json.NewEncoder(w).Encode(resp); err != nil {
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.
// The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel.
@@ -575,6 +662,17 @@ func tokenize(s string) []string {
return tokens
}
// withinTwoEdits reports whether a and b are at most 2 edits apart. Lengths
// differing by more than 2 rule that out without running levenshtein, which
// skips nearly every word pair in the per-query scoring loop.
func withinTwoEdits(a, b string) bool {
d := utf8.RuneCountInString(a) - utf8.RuneCountInString(b)
if d < -2 || d > 2 {
return false
}
return levenshtein(a, b) <= 2
}
// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
+1 -1
View File
@@ -91,7 +91,7 @@ func secionSpanEnd(sections [][]byte, start int) int {
if level == 0 {
return start + 1
}
for i := start + 1; i <= len(sections); i++ {
for i := start + 1; i < len(sections); i++ {
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
return i
}
+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.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
// its position among task list items in source order; the disabled attribute
// is removed so the user can toggle them.
// rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
// their source line. The disabled attribute is removed and each checkbox
// posts itself to ?toggle=N (htmx), N being its position among task list
// 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 {
idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked"))
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.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 {
out.WriteString(` checked=""`)
}
+48 -6
View File
@@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -48,8 +49,13 @@ var (
thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{}
digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
// photos otherwise decodes one full-size image per browser connection at
// once, which on the NAS means memory pressure and CPU thrash.
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
)
// digestEntry remembers the digest of a source file so repeated requests do
@@ -96,9 +102,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
cleanPath := path.Clean(decoded)
h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
}
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
// serveThumbnail generates (or serves from cache) a thumbnail of the file at
// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
// route used by listings and the diary, and the ?w= query on a file's own URL,
// which is the form embeds use so their <img src> stays relative to the page.
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
@@ -174,7 +185,10 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
return
}
if err := generateThumb(cacheFS, write); err != nil {
thumbGenSlots <- struct{}{}
err = generateThumb(cacheFS, write)
<-thumbGenSlots
if err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
@@ -194,6 +208,9 @@ func pathDigest(srcFS string, info os.FileInfo) string {
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse.
//
// Hits come from memory or, after a restart, from the on-disk index (see
// digestIndexPath), so an unchanged file is hashed only once ever.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock()
d, ok := digestCache[srcFS]
@@ -202,18 +219,43 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
return d.hex, nil, nil
}
indexFS := digestIndexPath(srcFS, info)
if b, err := os.ReadFile(indexFS); err == nil && len(b) == sha256.Size*2 {
h := string(b)
rememberDigest(srcFS, info, h)
return h, nil, nil
}
data, err := os.ReadFile(srcFS)
if err != nil {
return "", nil, err
}
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
rememberDigest(srcFS, info, h)
// Best effort: a failed write only costs a re-hash after the next restart.
if err := os.MkdirAll(filepath.Dir(indexFS), 0755); err == nil {
if err := writeFileAtomic(indexFS, []byte(h), 0644); err != nil {
log.Printf("thumb digest index %s: %v", indexFS, err)
}
}
return h, data, nil
}
// digestIndexPath is where the content digest of srcFS is persisted, keyed on
// path + mtime + size like pathDigest, so overwriting the file misses and
// re-hashes. Entries for changed or deleted files are left behind; each is 64
// bytes.
func digestIndexPath(srcFS string, info os.FileInfo) string {
key := pathDigest(srcFS, info)
return filepath.Join(thumbCacheDir, "digests", key[:2], key)
}
func rememberDigest(srcFS string, info os.FileInfo, h string) {
digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock()
return h, data, nil
}
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
+43 -16
View File
@@ -57,6 +57,14 @@ func resizeBox(src image.Image, width int) image.Image {
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
// JPEGs decode to *image.YCbCr. Reading its planes directly avoids an
// interface call and a colour conversion per source pixel, which makes the
// resize ~4x faster; everything else takes the generic At() path.
avg := boxAvg
if ycc, ok := src.(*image.YCbCr); ok {
avg = func(_ image.Image, r image.Rectangle) color.RGBA { return boxAvgYCbCr(ycc, r) }
}
for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH
@@ -69,23 +77,42 @@ func resizeBox(src image.Image, width int) image.Image {
if sx1 == sx0 {
sx1 = sx0 + 1
}
var r, g, bl, n uint64
for sy := sy0; sy < sy1; sy++ {
for sx := sx0; sx < sx1; sx++ {
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min)
dst.SetRGBA(x, y, avg(src, box))
}
}
return dst
}
// boxAvg returns the mean colour of the pixels of src inside r.
func boxAvg(src image.Image, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
sr, sg, sb, _ := src.At(x, y).RGBA()
red += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly.
// Each pixel is still converted to RGB before averaging so out-of-gamut
// samples clamp exactly as they do through At().
func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
ci := src.COffset(x, y)
sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci])
red += uint64(sr)
g += uint64(sg)
bl += uint64(sb)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
)
// testPhoto returns a JPEG-decoded (so *image.YCbCr, 4:2:0) image with an odd
// size and a non-zero origin to exercise the plane offset arithmetic.
func testPhoto(t testing.TB, w, h int) *image.YCbCr {
t.Helper()
src := image.NewRGBA(image.Rect(0, 0, w, h))
for i := range src.Pix {
src.Pix[i] = uint8(i*7 + i/w)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
img, err := jpeg.Decode(&buf)
if err != nil {
t.Fatal(err)
}
return img.(*image.YCbCr).SubImage(image.Rect(3, 5, w, h)).(*image.YCbCr)
}
func TestResizeBoxYCbCrMatchesGeneric(t *testing.T) {
src := testPhoto(t, 1001, 667)
fast := resizeBox(src, 120).(*image.RGBA)
// Hide the concrete type so resizeBox takes the generic At() path.
slow := resizeBox(struct{ image.Image }{src}, 120).(*image.RGBA)
if fast.Bounds() != slow.Bounds() {
t.Fatalf("bounds %v != %v", fast.Bounds(), slow.Bounds())
}
for i := range fast.Pix {
if fast.Pix[i] != slow.Pix[i] {
t.Fatalf("pixel byte %d: fast %d, generic %d", i, fast.Pix[i], slow.Pix[i])
}
}
}
func BenchmarkResizeBox(b *testing.B) {
src := testPhoto(b, 4000, 3000)
b.Run("ycbcr", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(src, 300)
}
})
b.Run("generic", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resizeBox(struct{ image.Image }{src}, 300)
}
})
}
// After a restart (empty memory cache) the digest comes from the on-disk index
// without reading the source file.
func TestSourceDigestPersists(t *testing.T) {
old := thumbCacheDir
thumbCacheDir = t.TempDir()
t.Cleanup(func() { thumbCacheDir = old })
src := filepath.Join(t.TempDir(), "a.jpg")
if err := os.WriteFile(src, []byte("photo"), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(src)
h1, data, err := sourceDigest(src, info)
if err != nil || data == nil {
t.Fatalf("first call: %v, data=%v", err, data)
}
digestCacheMu.Lock()
delete(digestCache, src)
digestCacheMu.Unlock()
// Removing the source proves the second call does not read it again.
if err := os.Remove(src); err != nil {
t.Fatal(err)
}
h2, data, err := sourceDigest(src, info)
if err != nil || data != nil || h2 != h1 {
t.Fatalf("after restart: h=%s data=%v err=%v, want %s from index", h2, data, err, h1)
}
}
+168 -53
View File
@@ -1,30 +1,71 @@
package main
import (
"encoding/json"
"bytes"
"html/template"
"log"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"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 {
Name string `json:"name"`
Kind string `json:"kind"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
Name string
Kind string // "folder" or "file"
// HasChildren reports whether a folder has any visible entries (under the
// same files filter), so empty folders render without a chevron.
HasChildren bool
}
type treeResponse struct {
Path string `json:"path"`
Entries []treeEntry `json:"entries"`
// treeRow is one rendered row. Folders with children are expandable: either
// pre-expanded (Children set, along the requested path) or lazy-loaded from
// 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
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
// descended — the client lazy-loads children on expand.
// handleTree responds with the rendered rows of the folder at fsPath:
//
// ?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) {
info, err := os.Stat(fsPath)
if err != nil {
@@ -40,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
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 {
http.Error(w, "read failed", http.StatusInternalServerError)
return
}
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
if dir == "/" && q.Has("root") && o.Pick != "" {
root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
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:
// "/" for root, otherwise stripped of any trailing slash.
func canonicalTreePath(urlPath string) string {
if urlPath == "" || urlPath == "/" {
return "/"
// sidebarTree renders the navigation tree for the page at urlPath: the root
// listing with the chain down to the page expanded and the page highlighted.
// The tree is part of the page itself, so the rail never paints empty.
func sidebarTree(root, urlPath string) template.HTML {
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
@@ -81,7 +190,11 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
continue
}
if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
folders = append(folders, treeEntry{
Name: name,
Kind: "folder",
HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly),
})
} else {
if foldersOnly || name == "index.md" {
continue
@@ -94,6 +207,33 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
return append(folders, files...), nil
}
// folderHasChildren reports whether fsPath contains at least one entry that
// listTreeEntries would list. It reads the directory in small batches and
// stops at the first match, so large folders stay cheap. Unreadable folders
// report false.
func folderHasChildren(fsPath string, foldersOnly bool) bool {
f, err := os.Open(fsPath)
if err != nil {
return false
}
defer f.Close()
for {
batch, err := f.ReadDir(32)
for _, e := range batch {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if e.IsDir() || (!foldersOnly && name != "index.md") {
return true
}
}
if err != nil {
return false
}
}
}
// treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string {
var segs []string
@@ -104,28 +244,3 @@ func treePathSegments(p string) []string {
}
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)
}
}
+48 -116
View File
@@ -3,8 +3,6 @@ package main
import (
"bytes"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
@@ -16,20 +14,17 @@ import (
"github.com/yuin/goldmark/util"
)
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
// of the current inline reader. Target and display forbid newlines and
// brackets; the target is non-greedy so the first `::` separates target from
// display when both are present.
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
// the current inline reader. Query and display forbid newlines and brackets;
// the query is non-greedy so the first `::` separates query from display when
// both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
// Used by the move-endpoint rewriter; not by the goldmark parser.
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser.
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
// normalized by wikiSearchQuery.
type wikiLinkNode struct {
ast.BaseInline
Target []byte
Query []byte
Display []byte
}
@@ -39,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target),
"Query": string(n.Query),
"Display": string(n.Display),
}, nil)
}
// isValidWikiTarget rejects targets that are not absolute or that contain
// traversal / empty segments. Matches the validation used by the move endpoint.
func isValidWikiTarget(target []byte) bool {
if len(target) == 0 || target[0] != '/' {
return false
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
// A leading `/` marks a link written under the old path-based syntax; those
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
// existing content keeps resolving without a rewrite. Everything else is free
// text and passes through with only surrounding space trimmed.
func wikiSearchQuery(raw string) string {
q := strings.TrimSpace(raw)
if !strings.HasPrefix(q, "/") {
return q
}
trimmed := strings.Trim(string(target), "/")
if trimmed == "" {
return true // root link
trimmed := strings.Trim(q, "/")
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
return strings.TrimSpace(trimmed[i+1:])
}
for _, seg := range strings.Split(trimmed, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
return trimmed
}
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only
// URL-safe bytes, so the result needs no further HTML escaping in an href.
func wikiSearchHref(query string) string {
return "/?q=" + url.QueryEscape(query)
}
type wikiLinkParser struct{}
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
if m == nil {
return nil
}
target := bytes.TrimSpace(line[m[2]:m[3]])
if !isValidWikiTarget(target) {
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
// not a link; leave the source text alone.
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil
}
var display []byte
@@ -85,76 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
}
block.Advance(m[1])
return &wikiLinkNode{
Target: append([]byte(nil), target...),
Query: []byte(query),
Display: append([]byte(nil), display...),
}
}
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
// returns the cleaned absolute path.
func normalizeWikiTarget(target string) string {
if target == "/" {
return "/"
}
return "/" + strings.Trim(target, "/")
}
// wikiTargetHref converts a wiki target to a URL href with each segment
// percent-encoded and a trailing slash appended.
func wikiTargetHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
b.WriteByte('/')
return b.String()
}
// wikiFileHref converts a wiki target to a URL href for the raw file: each
// segment is percent-encoded and, unlike wikiTargetHref, no trailing slash is
// appended. Used for image-embed click-through so the href points straight at
// the file (companion click-interception skips anything ending in "/").
func wikiFileHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
return b.String()
}
// wikiTargetExists reports whether the on-disk path backing the target exists
// under root. Any existing path — file or folder — counts as resolved; only a
// missing path is treated as broken.
func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
_, err := os.Stat(fsPath)
return err == nil
}
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
func wikiDefaultDisplay(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
return segs[len(segs)-1]
}
type wikiLinkRenderer struct {
root string
}
type wikiLinkRenderer struct{}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiLink, r.render)
@@ -165,38 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil
}
n := node.(*wikiLinkNode)
writeWikiLink(w, r.root, string(n.Target), string(n.Display))
writeWikiSearchLink(w, string(n.Query), string(n.Display))
return ast.WalkContinue, nil
}
// writeWikiLink renders a wiki-link anchor for target with an optional display
// override. An empty display falls back to the target's last segment; a target
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
// renderer and the embed renderer's link fallback.
func writeWikiLink(w util.BufWriter, root, target, display string) {
href := wikiTargetHref(target)
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// search page, labelled with the display override or the query itself. Nothing
// is resolved at render time — whether the query matches anything is the search
// page's business, so there is no broken state here.
func writeWikiSearchLink(w util.BufWriter, query, display string) {
if display == "" {
display = wikiDefaultDisplay(target)
display = query
}
broken := !wikiTargetExists(root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.WriteString(wikiSearchHref(query))
w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
}
type wikiLinkExt struct{ root string }
type wikiLinkExt struct{}
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links resolved against root.
func newWikiLinkExt(root string) goldmark.Extender {
return &wikiLinkExt{root: root}
// links to the search page. It needs no wiki root — a query is never resolved
// against the filesystem.
func newWikiLinkExt() goldmark.Extender {
return &wikiLinkExt{}
}
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
@@ -206,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
util.Prioritized(&wikiLinkParser{}, 199),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
util.Prioritized(&wikiLinkRenderer{}, 500),
))
}