Compare commits

1 Commits
Author SHA1 Message Date
luxick b48d476b39 JSON canvas initial 2026-09-22 21:28:28 +02:00
94 changed files with 5114 additions and 8472 deletions
+4 -13
View File
@@ -4,24 +4,20 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64 COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
# Release builds drop the symbol table, DWARF debug info and local source
# paths. Panics still print file:line stack traces; only debuggers lose out.
RELEASE_FLAGS := -trimpath -ldflags="-s -w"
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
.PHONY: deploy companion companion-windows companion-linux companion-release editor e2e .PHONY: deploy companion companion-windows companion-linux companion-release editor
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist # Cross-compiled companion artifacts the wiki binary embeds. Both must exist
# before `go build .` so embed.FS picks them up. # before `go build .` so embed.FS picks them up.
companion-release: $(COMPANION_WIN) $(COMPANION_LIN) companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
$(COMPANION_WIN): $(COMPANION_SRCS) $(COMPANION_WIN): $(COMPANION_SRCS)
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H windowsgui" -o $@ ./cmd/companion GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion
$(COMPANION_LIN): $(COMPANION_SRCS) $(COMPANION_LIN): $(COMPANION_SRCS)
GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -o $@ ./cmd/companion GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
companion-windows: $(COMPANION_WIN) companion-windows: $(COMPANION_WIN)
companion-linux: $(COMPANION_LIN) companion-linux: $(COMPANION_LIN)
@@ -41,13 +37,8 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS)
cd editor-build && npm ci && npm run build cd editor-build && npm ci && npm run build
deploy: companion-release deploy: companion-release
GOOS=linux GOARCH=arm GOARM=7 go build $(RELEASE_FLAGS) -o datascape-arm . GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid' ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
rm datascape-arm rm datascape-arm
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
e2e:
cd e2e && npm install --no-audit --no-fund && npx playwright test
+145 -104
View File
@@ -4,149 +4,190 @@ Minimal self-hosted personal wiki. Folders are pages.
## Features ## Features
- **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. - **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.
- **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.
## Build & run - **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.
The wiki binary embeds the companion binaries, so build those first: - **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `![caption](photo.jpg "left")` — relative to the page they sit on.
- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
## Build
```bash ```bash
make companion-release # once, and after companion changes # local
go build -o datascape . # local go build -o datascape .
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
./datascape -dir ./wiki -addr :8080 -user me -pass secret # QNAP NAS (linux/arm)
GOOS=linux GOARCH=arm go build -o datascape .
``` ```
| Flag | Default | | ## Usage
|------|---------|-|
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
| Flag | Default | Description |
|------|---------|-------------|
| `-addr` | `:8080` | Listen address | | `-addr` | `:8080` | Listen address |
| `-dir` | `./wiki` | Wiki root | | `-dir` | `./wiki` | Wiki root directory |
| `-cache` | `./cache` | Thumbnail cache | | `-cache` | `./cache` | Thumbnail cache directory |
| `-user`, `-pass` | | Basic auth (omit to disable) | | `-user` | _(none)_ | Basic auth username — omit to disable auth |
| `-reindex-interval` | `30m` | Search index rebuild (`0` = off) | | `-pass` | _(none)_ | Basic auth password |
| `-calendars` | `Calendars` | [Calendars](#calendars) folder, relative to `-dir` (empty = off) | | `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
| `-tz` | system | Time zone for calendars and the diary, e.g. `Europe/Berlin` |
## `.page-settings` ## View Settings
Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys. The **view** button in a folder's `Files` header sets how its listing renders,
persisting three keys to `.page-settings`:
| Key | Values (default first) | | Key | Values (default first) |
|-----|------------------------| |------|------------------------|
| `view` | `list`, `thumbnail` | | `view` | `list`, `thumbnail` |
| `sort` | `name`, `modified`, `size` | | `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
| `order` | `asc`, `desc` | | `order` | `asc`, `desc` |
| `type` | `diary`, `fitness`, `calendars` |
## Keyboard shortcuts ## Special Folder Types
All use Alt+Shift. 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`
| Where | Keys | key selects a special renderer:
|-------|------|
| 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
``` ```
Diary/ type = diary
```
### Diary
Designed for a chronological photo diary. The whole year lives in a single
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
```
FolderName/
.page-settings ← type = diary .page-settings ← type = diary
2026/ YYYY/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
2026-05-11 Beach.jpg ← photos go under their date's section YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
``` ```
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. 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.
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`. 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.
## Fitness #### Persistent date links
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. Each diary root exposes three stable paths intended for browser bookmarks.
They resolve against the year page rather than separate per-day URLs:
## Calendars | 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/` |
``` Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
Calendars/
.page-settings ← type = calendars, roles, sync credentials
Personal.ics ← one file per calendar
Gewicht.ics
…
```
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. ## Canvases
``` A canvas is a `<Name>.canvas` file in a page folder holding a
type = calendars [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a
folder renders as a tab above that page's content, and the page itself is the
first tab — so a page and its diagrams are one thing with several views.
# Roles: file names without .ics | URL | What it does |
diary = Personal, Feiertage |------|-------------|
weight = Gewicht | `<page>/?canvas=<Name>` | the canvas view (canonical) |
maintenance = Maintainance | `<page>/<Name>.canvas` | redirects to the canonical form |
| `<page>/<Name>.canvas?raw` | the file itself |
# Sync (Nextcloud) Create one from the **NEW CANVAS** action or the `+` in the tab strip.
caldav-url = https://cloud.example/remote.php/dav/calendars/me/
caldav-user = me
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
sync-interval = 15m
```
| Key | | ### Nodes
|-----|-|
| `diary` | Events shown under each diary day: all-day first, then by start time |
| `weight` | Titles like `95,6kg`, shown under the diary day's events |
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
| `caldav-url` | Calendar home. Empty = no sync |
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder. All four spec node types render:
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar. | Type | Rendered as |
|------|-------------|
| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page |
| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs |
| `link` | a card for an external URL |
| `group` | a labelled frame; dragging it carries the nodes inside it |
## Quick-add bookmarklet Node content is rendered by the server and cached in the browser by *content*,
so the whole canvas arrives pre-rendered in the initial page load (no per-node
requests) and undo/redo of a text edit costs nothing.
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page: ### Controls
| Gesture | Action |
|---------|--------|
| drag background | pan |
| ctrl/⌘ + wheel, or pinch | zoom |
| shift + drag background | marquee select |
| drag a node | move it (snaps to a 20px grid; hold alt to bypass) |
| drag a corner handle | resize |
| drag a side port | draw an edge to another node |
| double-click a node | edit it |
| double-click empty space | new text node there |
Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete
selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) ·
`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel.
Undo covers the whole session; a drag or resize is one entry no matter how many
events it spanned.
### Saving
Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The
server parses and validates it before writing anything, so a malformed payload
can never land on disk, and four things are checked in order:
1. the JSON parses into the spec's shape;
2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range;
3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite;
4. a save that would empty a populated canvas is refused until you confirm it.
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
write itself is atomic. Leaving the page with unsaved changes prompts first.
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they
keep resolving after a move. File nodes and group backgrounds hold literal
paths and are not rewritten: moving or renaming their target leaves them
rendered as broken references.
Canvas *contents* are not searchable; only the file name is indexed.
## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
```javascript ```javascript
javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})(); javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})();
``` ```
Each save adds this to the target page's `index.md`: Each save appends an entry of the following form to the destination page's `index.md`:
```markdown ```markdown
- [Example Page](https://example.com) - [Example Page](https://example.com)
-45
View File
@@ -1,45 +0,0 @@
<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
@@ -1,16 +0,0 @@
<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
+182
View File
@@ -0,0 +1,182 @@
// doc.js — the canvas document plus its undo stack.
//
// Every mutation passes through begin()/commit(). A gesture (a drag, a resize,
// a nudge-repeat) spans hundreds of pointer events but has to collapse into a
// single undo entry, so the snapshot is taken once at begin() and pushed once
// at commit() — and only when the document actually changed, so a click that
// moves nothing leaves no trace.
//
// Undo is snapshot-based rather than a stack of inverse operations: the
// document is a small pure-JSON object, cloning it costs microseconds, and a
// snapshot cannot be subtly wrong the way a hand-written inverse can. The
// stack is capped so a long session cannot grow without bound.
window.Canvas = window.Canvas || {};
Canvas.Doc = (function () {
var MAX_UNDO = 100;
var doc = { nodes: [], edges: [] };
var undoStack = [];
var redoStack = [];
var pending = null;
var dirty = false;
var version = '';
var listeners = [];
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
// newId mints a 16-hex-character node/edge id, the shape the format uses in
// the wild. Collisions are checked against the live document anyway.
function newId() {
var bytes = new Uint8Array(8);
window.crypto.getRandomValues(bytes);
var out = '';
for (var i = 0; i < bytes.length; i++) {
out += (bytes[i] + 0x100).toString(16).slice(1);
}
if (find(doc.nodes, out) || find(doc.edges, out)) return newId();
return out;
}
function find(list, id) {
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
return {
init: function (d, hash) {
doc = (d && typeof d === 'object') ? d : {};
if (!Array.isArray(doc.nodes)) doc.nodes = [];
if (!Array.isArray(doc.edges)) doc.edges = [];
undoStack = [];
redoStack = [];
pending = null;
dirty = false;
version = hash || '';
},
get: function () { return doc; },
nodes: function () { return doc.nodes; },
edges: function () { return doc.edges; },
node: function (id) { return find(doc.nodes, id); },
edge: function (id) { return find(doc.edges, id); },
newId: newId,
// edgesFor returns every edge touching any of the given node ids.
edgesFor: function (nodeIds) {
var want = {};
nodeIds.forEach(function (id) { want[id] = true; });
return doc.edges.filter(function (e) {
return want[e.fromNode] || want[e.toNode];
});
},
// --- gesture bracketing ---
//
// Nested begin() calls join the outer gesture rather than starting a
// new one, so a composite action (drag a group, which drags its
// members) still produces exactly one undo entry.
begin: function (label) {
if (pending) { pending.depth++; return; }
pending = { label: label || '', before: clone(doc), depth: 0 };
},
commit: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
var before = pending.before;
var label = pending.label;
pending = null;
// A gesture that changed nothing (a click that selected without
// moving) must not push an undo entry the user would have to press
// Ctrl+Z through.
if (JSON.stringify(before) === JSON.stringify(doc)) return;
undoStack.push({ label: label, doc: before });
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack.length = 0;
dirty = true;
emit();
},
// abort rolls the document back to the gesture's starting state, for
// Escape mid-drag.
abort: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
doc = pending.before;
pending = null;
emit();
},
inGesture: function () { return pending !== null; },
undo: function () {
if (!undoStack.length) return false;
var entry = undoStack.pop();
redoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
redo: function () {
if (!redoStack.length) return false;
var entry = redoStack.pop();
undoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
canUndo: function () { return undoStack.length > 0; },
canRedo: function () { return redoStack.length > 0; },
// --- structural helpers ---
addNode: function (node) {
doc.nodes.push(node);
return node;
},
addEdge: function (edge) {
doc.edges.push(edge);
return edge;
},
// remove drops the named nodes and edges. Every edge touching a removed
// node goes too — a dangling endpoint would fail validation on save, so
// it can never be allowed to exist even transiently.
remove: function (nodeIds, edgeIds) {
var deadNodes = {};
(nodeIds || []).forEach(function (id) { deadNodes[id] = true; });
var deadEdges = {};
(edgeIds || []).forEach(function (id) { deadEdges[id] = true; });
doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; });
doc.edges = doc.edges.filter(function (e) {
return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode];
});
},
// --- save state ---
isDirty: function () { return dirty; },
markSaved: function (hash) {
dirty = false;
if (hash) version = hash;
emit();
},
version: function () { return version; },
onChange: function (fn) { listeners.push(fn); },
notify: emit
};
})();
+237
View File
@@ -0,0 +1,237 @@
// edges.js — the SVG edge layer.
//
// Edges are cubic beziers anchored at the midpoint of a node side, with control
// points pushed out along that side's normal. When a side is unspecified the
// dominant axis between the two centres picks one, which keeps auto-routed
// edges predictable instead of hunting for a global minimum.
//
// The SVG sits inside the transformed world and draws in world coordinates, so
// it pans and zooms with the nodes for free.
window.Canvas = window.Canvas || {};
Canvas.Edges = (function () {
var Doc = Canvas.Doc;
var Select = Canvas.Select;
var SVGNS = 'http://www.w3.org/2000/svg';
var ARROW = 11;
var svg = null;
var groups = {}; // edge id -> <g>
var temp = null; // the rubber-band path while drawing a new edge
function el(name, attrs) {
var node = document.createElementNS(SVGNS, name);
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
}
return node;
}
// sideAnchor returns the connection point on a node side together with that
// side's outward normal, which the control points and arrowheads both need.
function sideAnchor(n, side) {
switch (side) {
case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 };
case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 };
case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 };
default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 };
}
}
// autoSides picks the facing sides for an edge with no explicit ones: the
// dominant axis between the node centres wins. Simple and stable — the edge
// does not flip sides every time a node moves a few pixels.
function autoSides(a, b) {
var dx = (b.x + b.width / 2) - (a.x + a.width / 2);
var dy = (b.y + b.height / 2) - (a.y + a.height / 2);
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0 ? ['right', 'left'] : ['left', 'right'];
}
return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom'];
}
function geometry(e) {
var a = Doc.node(e.fromNode);
var b = Doc.node(e.toNode);
if (!a || !b) return null;
var auto = autoSides(a, b);
var fromSide = e.fromSide || auto[0];
// A self-loop has no meaningful "facing" side, so it gets a fixed pair
// that always produces a visible arc out of and back into the node.
var toSide = e.toSide || (a === b ? 'top' : auto[1]);
var p0 = sideAnchor(a, fromSide);
var p3 = sideAnchor(b, toSide);
var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y));
var k = Math.max(40, dist / 3);
return {
p0: p0,
p3: p3,
c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k },
c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k }
};
}
function curve(g) {
return 'M' + g.p0.x + ',' + g.p0.y +
'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y +
' ' + g.p3.x + ',' + g.p3.y;
}
// midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to
// place a label.
function midpoint(g) {
return {
x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8,
y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8
};
}
// arrowhead draws a triangle with its tip at pt, its base offset along the
// outward normal — so the arrow points into the node it terminates on.
function arrowhead(pt) {
var bx = pt.x + pt.nx * ARROW;
var by = pt.y + pt.ny * ARROW;
var px = -pt.ny * ARROW * 0.42;
var py = pt.nx * ARROW * 0.42;
return 'M' + pt.x + ',' + pt.y +
'L' + (bx + px) + ',' + (by + py) +
'L' + (bx - px) + ',' + (by - py) + 'Z';
}
function colorVar(c) {
if (!c) return 'var(--canvas-edge)';
if (c.charAt(0) === '#') return c;
return 'var(--canvas-' + c + ')';
}
function build(e) {
var g = el('g', { 'class': 'canvas-edge' });
g.dataset.id = e.id;
// A transparent fat stroke under the visible line is the hit target:
// a 2px curve is essentially unclickable otherwise.
g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' }));
var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' });
g.appendChild(label);
return g;
}
function update(g, e) {
var geo = geometry(e);
if (!geo) { g.setAttribute('display', 'none'); return; }
g.removeAttribute('display');
var d = curve(geo);
var stroke = colorVar(e.color);
g.querySelector('.canvas-edge-hit').setAttribute('d', d);
var line = g.querySelector('.canvas-edge-line');
line.setAttribute('d', d);
line.setAttribute('stroke', stroke);
// Spec defaults: fromEnd is "none", toEnd is "arrow".
var toHead = g.querySelector('.canvas-edge-head-to');
if ((e.toEnd || 'arrow') === 'arrow') {
toHead.setAttribute('d', arrowhead(geo.p3));
toHead.setAttribute('fill', stroke);
toHead.removeAttribute('display');
} else {
toHead.setAttribute('display', 'none');
}
var fromHead = g.querySelector('.canvas-edge-head-from');
if ((e.fromEnd || 'none') === 'arrow') {
fromHead.setAttribute('d', arrowhead(geo.p0));
fromHead.setAttribute('fill', stroke);
fromHead.removeAttribute('display');
} else {
fromHead.setAttribute('display', 'none');
}
var label = g.querySelector('.canvas-edge-label');
if (e.label) {
var m = midpoint(geo);
label.setAttribute('x', m.x);
label.setAttribute('y', m.y);
label.textContent = e.label;
label.removeAttribute('display');
} else {
label.setAttribute('display', 'none');
}
g.classList.toggle('is-selected', Select.hasEdge(e.id));
}
return {
init: function (svgEl) { svg = svgEl; },
sideAnchor: sideAnchor,
autoSides: autoSides,
render: function () {
var seen = {};
Doc.edges().forEach(function (e) {
seen[e.id] = true;
var g = groups[e.id];
if (!g) {
g = build(e);
groups[e.id] = g;
svg.appendChild(g);
}
update(g, e);
});
Object.keys(groups).forEach(function (id) {
if (seen[id]) return;
groups[id].remove();
delete groups[id];
});
},
// syncFor repaints only the edges touching the given nodes. Called on
// every frame of a drag, where re-running the full reconcile would be
// pure overhead.
syncFor: function (nodeIds) {
Doc.edgesFor(nodeIds).forEach(function (e) {
var g = groups[e.id];
if (g) update(g, e);
});
},
syncSelection: function () {
Doc.edges().forEach(function (e) {
var g = groups[e.id];
if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id));
});
},
// --- rubber band shown while dragging a new edge out of a port ---
beginTemp: function () {
if (temp) temp.remove();
temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' });
svg.appendChild(temp);
},
updateTemp: function (node, side, to) {
if (!temp) return;
var p0 = sideAnchor(node, side);
var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y));
var k = Math.max(40, dist / 3);
temp.setAttribute('d',
'M' + p0.x + ',' + p0.y +
'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) +
' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y);
},
endTemp: function () {
if (!temp) return;
temp.remove();
temp = null;
}
};
})();
+204
View File
@@ -0,0 +1,204 @@
// edit.js — editing a node's content.
//
// Text nodes get the vendored CodeMirror editor in place, positioned over the
// node in *screen* coordinates rather than mounted inside the zoomed world.
// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent
// wrecks caret placement and selection maths; a fixed-position panel sidesteps
// that entirely and stays sharp at every zoom level.
//
// The other node types have nothing to type into, so they reuse the existing
// tree picker and modal instead of an editor.
window.Canvas = window.Canvas || {};
Canvas.Edit = (function () {
var Doc = Canvas.Doc;
var View = Canvas.View;
var MIN_WIDTH = 360;
var MIN_HEIGHT = 200;
var panel = null;
var view = null;
var currentId = null;
var original = '';
function destroy() {
if (view) { view.destroy(); view = null; }
if (panel) { panel.remove(); panel = null; }
currentId = null;
original = '';
document.removeEventListener('pointerdown', onOutside, true);
}
function commit() {
if (!panel || !view) return;
var text = view.state.doc.toString();
var id = currentId;
destroy();
var n = Doc.node(id);
if (!n || text === original) return;
Doc.begin('edit text');
n.text = text;
Doc.commit();
}
function onOutside(e) {
if (!panel || panel.contains(e.target)) return;
commit();
}
// screenRect places the editor over the node it edits, clamped to a usable
// minimum and kept inside the viewport — a 60x60 node still needs a panel
// big enough to type in.
function screenRect(n) {
var s = View.scale();
var tl = View.toScreen(n.x, n.y);
var w = Math.max(MIN_WIDTH, n.width * s);
var h = Math.max(MIN_HEIGHT, n.height * s);
w = Math.min(w, window.innerWidth - 16);
h = Math.min(h, window.innerHeight - 16);
var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8));
var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8));
return { left: left, top: top, width: w, height: h };
}
function openText(n) {
var rect = screenRect(n);
panel = document.createElement('div');
panel.className = 'canvas-editor panel panel-floating';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.width = rect.width + 'px';
panel.style.height = rect.height + 'px';
var hint = document.createElement('div');
hint.className = 'panel-header';
hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel';
panel.appendChild(hint);
var mount = document.createElement('div');
mount.className = 'canvas-editor-mount';
panel.appendChild(mount);
document.body.appendChild(panel);
currentId = n.id;
original = n.text || '';
view = new CM.EditorView({
state: CM.EditorState.create({
doc: original,
selection: { anchor: original.length },
extensions: [
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
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,
// Mod-Enter goes first (nothing else claims it); Escape goes
// last so an open completion popup still gets first refusal
// on it and closes itself instead of discarding the edit.
CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }]
.concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab],
[{ key: 'Escape', run: function () { destroy(); return true; } }]
))
]
}),
parent: mount
});
view.focus();
// Capture phase: a click on a toolbar button should apply the edit
// before that button's own handler reads the document.
document.addEventListener('pointerdown', onOutside, true);
}
function openFile(n) {
var start = (n.file || '/').replace(/\/+$/, '') || '/';
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: start,
preselect: start,
confirmLabel: 'USE',
onSelect: function (p) {
if (!p || p === n.file) return;
Doc.begin('retarget file node');
n.file = p;
n.subpath = '';
Doc.commit();
}
});
}
function promptValue(title, label, value, onValue) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = label;
input.value = value || '';
openModal({
title: title,
body: input,
confirm: {
label: 'OK',
onConfirm: function () {
closeModal();
onValue(input.value.trim());
}
}
});
}
function openLink(n) {
promptValue('Link node', 'https://example.com', n.url, function (v) {
if (!v || v === n.url) return;
Doc.begin('edit link');
n.url = v;
Doc.commit();
});
}
function openGroup(n) {
promptValue('Group label', 'Label', n.label, function (v) {
if (v === (n.label || '')) return;
Doc.begin('edit group label');
if (v) n.label = v; else delete n.label;
Doc.commit();
});
}
// close applies whatever is currently open, if anything.
function close() {
if (panel) commit();
}
return {
open: function (id) {
var n = Doc.node(id);
if (!n) return;
close();
switch (n.type) {
case 'text': openText(n); break;
case 'file': openFile(n); break;
case 'link': openLink(n); break;
case 'group': openGroup(n); break;
}
},
// Called from the gesture layer on any pointerdown on the surface, so
// starting a drag never silently drops an edit.
close: close,
isOpen: function () { return panel !== null; }
};
})();
+461
View File
@@ -0,0 +1,461 @@
// interact.js — pointer and keyboard gestures.
//
// Every gesture is bracketed by Doc.begin()/Doc.commit(), so a drag that spans
// hundreds of pointermove events collapses into one undo entry. Within a
// gesture the document is mutated directly and only the affected elements are
// re-synced; the full reconcile happens once, on commit.
//
// Gesture routing on pointerdown, in priority order:
// resize handle -> resize port -> draw an edge
// node -> select + drag edge -> select
// background -> pan (shift: marquee select; two fingers: pinch zoom)
window.Canvas = window.Canvas || {};
Canvas.Interact = (function () {
var Doc = Canvas.Doc;
var View = Canvas.View;
var Select = Canvas.Select;
var Nodes = Canvas.Nodes;
var Edges = Canvas.Edges;
var MIN_SIZE = 40;
var DRAG_SLOP = 3;
var surface = null;
var marquee = null;
var gesture = null;
var pointers = {};
function snap(v) {
return Math.round(v / View.GRID) * View.GRID;
}
function pointerCount() {
return Object.keys(pointers).length;
}
// interactive reports whether the pointer landed on something the browser
// should handle itself (a link, a video control), which must not become the
// start of a node drag.
function interactive(target) {
return !!target.closest('a, button, input, select, textarea, video, embed, iframe');
}
// groupMembers returns the nodes whose centre sits inside a group, which is
// what "inside" means for dragging: a node straddling the border belongs to
// whichever side holds most of it.
function groupMembers(g) {
return Doc.nodes().filter(function (n) {
if (n.id === g.id) return false;
var cx = n.x + n.width / 2;
var cy = n.y + n.height / 2;
return cx >= g.x && cx <= g.x + g.width && cy >= g.y && cy <= g.y + g.height;
}).map(function (n) { return n.id; });
}
// withGroupMembers expands a selection so dragging a group carries whatever
// it contains.
function withGroupMembers(ids) {
var out = {};
ids.forEach(function (id) {
out[id] = true;
var n = Doc.node(id);
if (n && n.type === 'group') {
groupMembers(n).forEach(function (m) { out[m] = true; });
}
});
return Object.keys(out);
}
function selectOnPress(id, additive) {
if (additive) {
Select.toggleNode(id);
return;
}
if (!Select.hasNode(id)) Select.set([id], []);
}
// --- gesture starts ---
function startDrag(id, e) {
selectOnPress(id, e.shiftKey);
if (!Select.hasNode(id)) return; // shift-click deselected it
var ids = withGroupMembers(Select.nodes());
Doc.begin('move');
gesture = {
kind: 'drag',
ids: ids,
start: View.toWorld(e.clientX, e.clientY),
origin: ids.map(function (nid) {
var n = Doc.node(nid);
return { id: nid, x: n.x, y: n.y };
}),
moved: false
};
}
function startResize(id, corner, e) {
Select.set([id], []);
var n = Doc.node(id);
Doc.begin('resize');
gesture = {
kind: 'resize',
id: id,
corner: corner,
start: View.toWorld(e.clientX, e.clientY),
origin: { x: n.x, y: n.y, width: n.width, height: n.height },
moved: false
};
}
function startEdge(id, side) {
Edges.beginTemp();
gesture = { kind: 'edge', fromId: id, fromSide: side, target: null, moved: false };
}
function startPan(e) {
gesture = { kind: 'pan', last: { x: e.clientX, y: e.clientY }, moved: false };
}
function startMarquee(e) {
var r = surface.getBoundingClientRect();
gesture = {
kind: 'marquee',
origin: { x: e.clientX - r.left, y: e.clientY - r.top },
additive: e.ctrlKey || e.metaKey,
moved: false
};
marquee.hidden = false;
updateMarquee(e);
}
function startPinch() {
var ids = Object.keys(pointers);
var a = pointers[ids[0]];
var b = pointers[ids[1]];
gesture = {
kind: 'pinch',
dist: Math.hypot(b.x - a.x, b.y - a.y),
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
moved: true
};
}
// --- gesture updates ---
function moveDrag(e) {
var p = View.toWorld(e.clientX, e.clientY);
var lead = gesture.origin[0];
if (!lead) return;
var dx = p.x - gesture.start.x;
var dy = p.y - gesture.start.y;
// Snap the lead node to the grid and shift the rest by the same amount,
// so a multi-node drag keeps its relative layout. Alt opts out.
if (!e.altKey) {
dx = snap(lead.x + dx) - lead.x;
dy = snap(lead.y + dy) - lead.y;
}
gesture.origin.forEach(function (o) {
var n = Doc.node(o.id);
if (!n) return;
n.x = Math.round(o.x + dx);
n.y = Math.round(o.y + dy);
});
gesture.ids.forEach(Nodes.syncGeom);
Edges.syncFor(gesture.ids);
}
function moveResize(e) {
var n = Doc.node(gesture.id);
if (!n) return;
var p = View.toWorld(e.clientX, e.clientY);
var o = gesture.origin;
var dx = p.x - gesture.start.x;
var dy = p.y - gesture.start.y;
var right = o.x + o.width;
var bottom = o.y + o.height;
var x = o.x, y = o.y, w = o.width, h = o.height;
if (gesture.corner.indexOf('w') !== -1) {
x = e.altKey ? o.x + dx : snap(o.x + dx);
w = right - x;
} else {
w = e.altKey ? o.width + dx : snap(right + dx) - o.x;
}
if (gesture.corner.indexOf('n') !== -1) {
y = e.altKey ? o.y + dy : snap(o.y + dy);
h = bottom - y;
} else {
h = e.altKey ? o.height + dy : snap(bottom + dy) - o.y;
}
// Clamp against the fixed edge so shrinking past the minimum pins the
// node instead of flipping it inside out.
if (w < MIN_SIZE) {
if (gesture.corner.indexOf('w') !== -1) x = right - MIN_SIZE;
w = MIN_SIZE;
}
if (h < MIN_SIZE) {
if (gesture.corner.indexOf('n') !== -1) y = bottom - MIN_SIZE;
h = MIN_SIZE;
}
n.x = Math.round(x);
n.y = Math.round(y);
n.width = Math.round(w);
n.height = Math.round(h);
Nodes.syncGeom(n.id);
Edges.syncFor([n.id]);
}
function moveEdge(e) {
var from = Doc.node(gesture.fromId);
if (!from) return;
Edges.updateTemp(from, gesture.fromSide, View.toWorld(e.clientX, e.clientY));
var under = document.elementFromPoint(e.clientX, e.clientY);
var nodeEl = under && under.closest ? under.closest('.canvas-node') : null;
var id = nodeEl ? nodeEl.dataset.id : null;
if (id === gesture.target) return;
if (gesture.target) {
var prev = Nodes.el(gesture.target);
if (prev) prev.classList.remove('is-edge-target');
}
gesture.target = id;
if (id && nodeEl) nodeEl.classList.add('is-edge-target');
}
function updateMarquee(e) {
var r = surface.getBoundingClientRect();
var x = e.clientX - r.left;
var y = e.clientY - r.top;
marquee.style.left = Math.min(x, gesture.origin.x) + 'px';
marquee.style.top = Math.min(y, gesture.origin.y) + 'px';
marquee.style.width = Math.abs(x - gesture.origin.x) + 'px';
marquee.style.height = Math.abs(y - gesture.origin.y) + 'px';
}
function movePinch() {
var ids = Object.keys(pointers);
if (ids.length < 2) return;
var a = pointers[ids[0]];
var b = pointers[ids[1]];
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
if (gesture.dist > 0) View.zoomAt(mid.x, mid.y, dist / gesture.dist);
View.pan(mid.x - gesture.mid.x, mid.y - gesture.mid.y);
gesture.dist = dist;
gesture.mid = mid;
}
// --- gesture ends ---
// Both finishers take the gesture as an argument rather than reading the
// module-level one: endGesture clears it before dispatching so a finisher
// cannot accidentally leave a half-torn-down gesture in place.
function finishMarquee(g) {
marquee.hidden = true;
var r = surface.getBoundingClientRect();
var a = View.toWorld(r.left + parseFloat(marquee.style.left),
r.top + parseFloat(marquee.style.top));
var b = View.toWorld(r.left + parseFloat(marquee.style.left) + parseFloat(marquee.style.width),
r.top + parseFloat(marquee.style.top) + parseFloat(marquee.style.height));
var hit = Doc.nodes().filter(function (n) {
return n.x < b.x && n.x + n.width > a.x && n.y < b.y && n.y + n.height > a.y;
}).map(function (n) { return n.id; });
if (g.additive) {
hit.forEach(Select.addNode);
} else {
Select.set(hit, []);
}
}
function finishEdge(g) {
if (g.target) {
var el = Nodes.el(g.target);
if (el) el.classList.remove('is-edge-target');
}
Edges.endTemp();
if (!g.target) return;
Doc.begin('connect');
Doc.addEdge({
id: Doc.newId(),
fromNode: g.fromId,
fromSide: g.fromSide,
toNode: g.target
});
Doc.commit();
}
// --- event wiring ---
function onDown(e) {
if (Canvas.Edit) Canvas.Edit.close();
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
if (pointerCount() === 2 && (!gesture || gesture.kind === 'pan')) {
if (gesture) endGesture(true);
startPinch();
return;
}
if (gesture) return; // one gesture at a time
var t = e.target;
var handle = t.closest('.canvas-handle');
var port = t.closest('.canvas-port');
var nodeEl = t.closest('.canvas-node');
var edgeEl = t.closest('.canvas-edge');
if (handle && nodeEl) {
startResize(nodeEl.dataset.id, handle.dataset.corner, e);
} else if (port && nodeEl) {
startEdge(nodeEl.dataset.id, port.dataset.side);
} else if (nodeEl) {
if (interactive(t)) return;
startDrag(nodeEl.dataset.id, e);
} else if (edgeEl) {
if (e.shiftKey) Select.toggleEdge(edgeEl.dataset.id);
else Select.set([], [edgeEl.dataset.id]);
return;
} else if (e.shiftKey) {
startMarquee(e);
} else {
startPan(e);
}
if (gesture) {
// Capture keeps the gesture alive when the pointer leaves the
// surface mid-drag. It throws when the pointer is already gone (a
// release that raced the handler), which must not abort the
// gesture — that would leave it stuck with no pointerup to end it.
try { surface.setPointerCapture(e.pointerId); } catch (err) { /* pointer already released */ }
e.preventDefault();
}
}
function onMove(e) {
if (pointers[e.pointerId]) {
pointers[e.pointerId].x = e.clientX;
pointers[e.pointerId].y = e.clientY;
}
if (!gesture) return;
if (gesture.kind === 'pinch') { movePinch(); return; }
if (!gesture.moved) {
// Ignore sub-pixel jitter so a plain click does not register as a
// drag (and so a click on the background still clears the selection).
var dx = Math.abs(e.movementX || 0);
var dy = Math.abs(e.movementY || 0);
if (dx + dy < DRAG_SLOP && gesture.kind !== 'edge') return;
gesture.moved = true;
}
switch (gesture.kind) {
case 'drag': moveDrag(e); break;
case 'resize': moveResize(e); break;
case 'edge': moveEdge(e); break;
case 'marquee': updateMarquee(e); break;
case 'pan':
View.pan(e.clientX - gesture.last.x, e.clientY - gesture.last.y);
gesture.last = { x: e.clientX, y: e.clientY };
break;
}
}
function endGesture(silent) {
if (!gesture) return;
var g = gesture;
gesture = null;
switch (g.kind) {
case 'drag':
case 'resize':
Doc.commit();
break;
case 'edge':
if (!silent) finishEdge(g); else Edges.endTemp();
break;
case 'marquee':
if (!silent) finishMarquee(g); else marquee.hidden = true;
break;
case 'pan':
// A press on the background that never moved is a click, and a
// click on empty space clears the selection.
if (!silent && !g.moved) Select.clear();
break;
}
}
function onUp(e) {
delete pointers[e.pointerId];
if (gesture && gesture.kind === 'pinch' && pointerCount() < 2) {
gesture = null;
return;
}
endGesture(false);
}
function onWheel(e) {
// Ctrl/meta+wheel is the zoom gesture every drawing tool uses, and is
// also what a trackpad pinch reports. A plain wheel scrolls the canvas.
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
View.zoomAt(e.clientX, e.clientY, Math.pow(0.999, e.deltaY));
return;
}
e.preventDefault();
if (e.shiftKey) View.pan(-e.deltaY, 0);
else View.pan(-e.deltaX, -e.deltaY);
}
function onDblClick(e) {
var nodeEl = e.target.closest('.canvas-node');
if (nodeEl) {
if (interactive(e.target)) return;
e.preventDefault();
Canvas.Edit.open(nodeEl.dataset.id);
return;
}
if (e.target.closest('.canvas-edge')) return;
// Double-clicking empty space drops a new text node there and opens it,
// which is how a canvas normally gets its first node.
var p = View.toWorld(e.clientX, e.clientY);
var node = Canvas.Actions.addText(p);
if (node) Canvas.Edit.open(node.id);
}
return {
init: function (surfaceEl, marqueeEl) {
surface = surfaceEl;
marquee = marqueeEl;
surface.addEventListener('pointerdown', onDown);
surface.addEventListener('pointermove', onMove);
surface.addEventListener('pointerup', onUp);
surface.addEventListener('pointercancel', onUp);
surface.addEventListener('wheel', onWheel, { passive: false });
surface.addEventListener('dblclick', onDblClick);
// Native drag would fight the pointer gestures on images and links.
surface.addEventListener('dragstart', function (e) { e.preventDefault(); });
surface.addEventListener('contextmenu', function (e) {
if (e.target.closest('.canvas-node, .canvas-edge')) e.preventDefault();
});
},
// cancel aborts an in-flight gesture and rolls the document back, for
// the Escape key.
cancel: function () {
if (!gesture) return false;
var kind = gesture.kind;
if (kind === 'drag' || kind === 'resize') {
gesture = null;
Doc.abort();
return true;
}
endGesture(true);
return true;
},
active: function () { return gesture !== null; }
};
})();
+82 -34
View File
@@ -1,40 +1,88 @@
{{/* 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"}} {{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" href="{{.PostURL}}" title="Back to the page">PAGE</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> <span class="dropdown">
<button type="button" class="btn dropdown-toggle" data-action="canvas-menu" title="Canvas actions">CANVAS▾</button>
<div class="dropdown-menu align-right">
<button type="button" class="btn btn-block" data-action="canvas-rename">Rename…</button>
<button type="button" class="btn btn-block danger" data-action="canvas-delete">Delete canvas</button>
</div>
</span>
<button class="btn" type="button" data-action="canvas-save" title="Save (Ctrl+S)">SAVE</button>
{{end}} {{end}}
{{define "shell"}} {{define "content"}}
<div class="canvas-app" data-version="{{.Version}}"{{if .Error}} data-readonly{{end}}> <script>
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}} // Full-bleed view mode: the canvas surface owns the viewport instead of the
<div class="canvas-stage"> // centred reading column. Set here rather than server-side for the same
<div class="canvas-viewport" tabindex="0"> // reason the editor does it — one class, no extra template plumbing.
<div class="canvas-world"> document.body.classList.add('canvas-mode');
<svg class="canvas-edges" aria-hidden="true"></svg> </script>
<div class="canvas-edge-labels"></div> {{if .CanvasError}}
<div class="canvas-nodes">{{range .Nodes}}{{template "canvas-node" .}}{{end}}</div> <div class="content">
</div> <h1>{{.ActiveCanvas}}</h1>
<p class="canvas-hint"{{if .Nodes}} hidden{{end}}>Double-click to add a note. Right-click or long-press for more.</p> <p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
</div> <p>Fix it in the file itself, then reload.
<div class="canvas-statusbar muted"><span class="canvas-status" aria-live="polite"></span><span class="canvas-zoom"></span></div> <a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
{{if not .Error}}<div class="canvas-editor-toolbar" hidden>{{template "editor-toolbar"}}</div>{{end}} <a href="{{.PostURL}}">back to the page</a></p>
</div>
<script type="application/json" id="canvas-data">{{.Doc}}</script>
</div> </div>
{{if not .Error}} {{else}}
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script> <div class="canvas-app">
<script src="{{asset "editor/tables.js"}}"></script> <nav class="canvas-tabs row" aria-label="Page views">
<script src="{{asset "editor/dates.js"}}"></script> <a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
<script src="{{asset "editor/movie.js"}}"></script> {{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
<script src="{{asset "editor/wikicomplete.js"}}"></script> </nav>
<script src="{{asset "editor/core.js"}}"></script> <div class="canvas-toolbar row">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo (Ctrl+Z)" disabled>↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="add-text" title="Add text node (T)">TEXT</button>
<button type="button" class="btn btn-tool" data-action="add-file" title="Add file node (F)">FILE</button>
<button type="button" class="btn btn-tool" data-action="add-link" title="Add link node (L)">LINK</button>
<button type="button" class="btn btn-tool" data-action="add-group" title="Add group (G)">GROUP</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" data-action="color-menu" title="Colour of the selection">■▾</button>
<div class="dropdown-menu canvas-color-menu">
<button type="button" class="btn btn-tool btn-block" data-color="">Default</button>
<button type="button" class="btn btn-tool btn-block" data-color="1"><span class="canvas-swatch" data-swatch="1"></span> Red</button>
<button type="button" class="btn btn-tool btn-block" data-color="2"><span class="canvas-swatch" data-swatch="2"></span> Orange</button>
<button type="button" class="btn btn-tool btn-block" data-color="3"><span class="canvas-swatch" data-swatch="3"></span> Yellow</button>
<button type="button" class="btn btn-tool btn-block" data-color="4"><span class="canvas-swatch" data-swatch="4"></span> Green</button>
<button type="button" class="btn btn-tool btn-block" data-color="5"><span class="canvas-swatch" data-swatch="5"></span> Cyan</button>
<button type="button" class="btn btn-tool btn-block" data-color="6"><span class="canvas-swatch" data-swatch="6"></span> Purple</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="delete-selection" title="Delete selection (Del)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="zoom-out" title="Zoom out (−)">−</button>
<button type="button" class="btn btn-tool" data-action="zoom-reset" title="Reset zoom to 100% (0)"><span data-canvas-zoom>100%</span></button>
<button type="button" class="btn btn-tool" data-action="zoom-in" title="Zoom in (+)">+</button>
<button type="button" class="btn btn-tool" data-action="fit" title="Fit to content (9)">FIT</button>
<span class="canvas-status muted" data-canvas-status></span>
</div>
<div class="canvas-surface" id="canvas-surface" tabindex="0">
<div class="canvas-world" id="canvas-world">
<div class="canvas-layer" id="canvas-groups"></div>
<svg class="canvas-edges" id="canvas-edges" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="canvas-layer" id="canvas-nodes"></div>
</div>
<div class="canvas-marquee" id="canvas-marquee" hidden></div>
<div class="canvas-hint muted" data-canvas-hint hidden>
Empty canvas — use the toolbar to add a node. Drag the background to pan, ctrl+wheel to zoom.
</div>
</div>
</div>
<script>window.CANVAS_DATA = {{.CanvasJSON}};</script>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/canvas/doc.js"></script>
<script src="/_/canvas/view.js"></script>
<script src="/_/canvas/select.js"></script>
<script src="/_/canvas/nodes.js"></script>
<script src="/_/canvas/edges.js"></script>
<script src="/_/canvas/interact.js"></script>
<script src="/_/canvas/edit.js"></script>
<script src="/_/canvas/save.js"></script>
<script src="/_/canvas/main.js"></script>
{{end}} {{end}}
<script src="{{asset "canvas/canvas.js"}}"></script>
{{end}} {{end}}
+389
View File
@@ -0,0 +1,389 @@
// main.js — bootstrap, toolbar, and keyboard shortcuts.
//
// The payload the server inlined (document + pre-rendered node HTML + the
// version token) is the only input; nothing else is fetched on load.
(function () {
var data = window.CANVAS_DATA;
var surface = document.getElementById('canvas-surface');
if (!data || !surface) return;
var Doc = Canvas.Doc;
var View = Canvas.View;
var Select = Canvas.Select;
var Nodes = Canvas.Nodes;
var Edges = Canvas.Edges;
var Save = Canvas.Save;
var world = document.getElementById('canvas-world');
var marquee = document.getElementById('canvas-marquee');
var hint = document.querySelector('[data-canvas-hint]');
var statusEl = document.querySelector('[data-canvas-status]');
var zoomEl = document.querySelector('[data-canvas-zoom]');
// Default sizes for new nodes, in world pixels. Multiples of the 20px grid
// so a fresh node lands aligned.
var SIZES = {
text: { width: 260, height: 120 },
file: { width: 400, height: 400 },
link: { width: 400, height: 140 },
group: { width: 520, height: 400 }
};
function snap(v) { return Math.round(v / View.GRID) * View.GRID; }
// --- document actions ---
Canvas.Actions = {
// add drops a node centred on a world point (the viewport centre when
// called from the toolbar, the cursor when called from a double-click).
add: function (type, extra, at) {
var size = SIZES[type];
var p = at || View.centreOfViewport();
var node = {
id: Doc.newId(),
type: type,
x: snap(p.x - size.width / 2),
y: snap(p.y - size.height / 2),
width: size.width,
height: size.height
};
for (var k in extra) {
if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k];
}
Doc.begin('add ' + type);
Doc.addNode(node);
Doc.commit();
Select.set([node.id], []);
return node;
},
addText: function (at) {
return Canvas.Actions.add('text', { text: '' }, at);
},
addGroup: function (at) {
return Canvas.Actions.add('group', { label: 'Group' }, at);
},
// A file node is meaningless without a target and a link node without a
// url, so both ask first and only then create — validation on the server
// rejects the empty forms anyway.
addFile: function () {
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: data.postUrl,
confirmLabel: 'ADD',
onSelect: function (p) {
if (p) Canvas.Actions.add('file', { file: p });
}
});
},
addLink: function () {
var input = document.createElement('input');
input.type = 'url';
input.className = 'input';
input.placeholder = 'https://example.com';
openModal({
title: 'Link node',
body: input,
confirm: {
label: 'ADD',
onConfirm: function () {
var v = input.value.trim();
if (!v) return;
closeModal();
Canvas.Actions.add('link', { url: v });
}
}
});
},
deleteSelection: function () {
if (Select.isEmpty()) return;
Doc.begin('delete');
Doc.remove(Select.nodes(), Select.edges());
Doc.commit();
Select.clear();
},
setColor: function (color) {
if (Select.isEmpty()) return;
Doc.begin('colour');
Select.nodes().forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
if (color) n.color = color; else delete n.color;
});
Select.edges().forEach(function (id) {
var e = Doc.edge(id);
if (!e) return;
if (color) e.color = color; else delete e.color;
});
Doc.commit();
},
// nudge moves the selection by whole grid steps, or single pixels with
// shift held for the cases where the grid is in the way.
nudge: function (dx, dy, fine) {
var ids = Select.nodes();
if (!ids.length) return;
var step = fine ? 1 : View.GRID;
Doc.begin('nudge');
ids.forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
n.x += dx * step;
n.y += dy * step;
});
Doc.commit();
},
selectAll: function () {
Select.set(
Doc.nodes().map(function (n) { return n.id; }),
Doc.edges().map(function (e) { return e.id; })
);
}
};
// --- chrome ---
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function updateToolbar() {
var undoBtn = document.querySelector('[data-action="undo"]');
var redoBtn = document.querySelector('[data-action="redo"]');
var delBtn = document.querySelector('[data-action="delete-selection"]');
if (undoBtn) undoBtn.disabled = !Doc.canUndo();
if (redoBtn) redoBtn.disabled = !Doc.canRedo();
if (delBtn) delBtn.disabled = Select.isEmpty();
var saveBtn = document.querySelector('[data-action="canvas-save"]');
if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty());
if (hint) hint.hidden = Doc.nodes().length > 0;
}
function updateZoom() {
if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%';
}
// refresh is the single re-render path: prune the selection against the
// (possibly undone) document, reconcile both layers, update the chrome.
function refresh() {
Select.prune(Doc.get());
Nodes.render();
Edges.render();
updateToolbar();
}
// --- canvas-level actions (rename / delete the file itself) ---
function leaveGuard() {
if (!Doc.isDirty()) return true;
return window.confirm('This canvas has unsaved changes. Leave anyway?');
}
function canvasAction(url, then) {
fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
.then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) { then(); return; }
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
})
.catch(function () { alert('Network error'); });
}
function renameCanvas() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.value = data.name;
openModal({
title: 'Rename canvas',
body: input,
confirm: {
label: 'RENAME',
onConfirm: function () {
var v = input.value.trim();
if (!v || v === data.name) { closeModal(); return; }
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) +
'&rename=' + encodeURIComponent(v),
function () {
window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v);
}
);
}
}
});
}
function deleteCanvas() {
openModal({
title: 'Delete canvas',
body: 'Delete "' + data.name + '"? The page itself is not affected.',
confirm: {
label: 'DELETE',
danger: true,
onConfirm: function () {
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1',
function () { window.location.href = data.postUrl; }
);
}
}
});
}
// --- wiring ---
function wireToolbar() {
var handlers = {
'undo': function () { Doc.undo(); },
'redo': function () { Doc.redo(); },
'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); },
'add-file': function () { Canvas.Actions.addFile(); },
'add-link': function () { Canvas.Actions.addLink(); },
'add-group': function () { Canvas.Actions.addGroup(); },
'delete-selection': function () { Canvas.Actions.deleteSelection(); },
'zoom-in': function () { View.zoomBy(1.2); },
'zoom-out': function () { View.zoomBy(1 / 1.2); },
'zoom-reset': function () { View.resetZoom(); },
'fit': function () { View.fit(Doc.nodes()); },
'canvas-save': function () { Save.run(); },
'canvas-rename': renameCanvas,
'canvas-delete': deleteCanvas
};
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var fn = handlers[btn.dataset.action];
if (!fn) return;
e.preventDefault();
fn();
});
document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) {
btn.addEventListener('click', function () {
Canvas.Actions.setColor(btn.dataset.color);
});
});
// The header dropdowns are wired on demand: the auto-wiring in
// global-shortcuts.js only covers the page's ACTIONS menu, which this
// view replaces.
['canvas-menu', 'color-menu'].forEach(function (action) {
wireDropdown(document.querySelector('[data-action="' + action + '"]'));
});
document.querySelectorAll('.canvas-tabs a').forEach(function (a) {
a.addEventListener('click', function (e) {
if (!leaveGuard()) e.preventDefault();
});
});
}
// typing reports whether the keystroke belongs to a text field, an open
// modal, or the node editor — in which case the canvas must keep its hands
// off it.
function typing(e) {
var t = e.target;
if (!t || !t.closest) return false;
return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]');
}
function wireKeys() {
document.addEventListener('keydown', function (e) {
if (typing(e)) return;
var mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault();
Save.run();
return;
}
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) Doc.redo(); else Doc.undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
Doc.redo();
return;
}
if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
Canvas.Actions.selectAll();
return;
}
if (mod) return;
switch (e.key) {
case 'Delete':
case 'Backspace':
e.preventDefault();
Canvas.Actions.deleteSelection();
break;
case 'Escape':
if (!Canvas.Interact.cancel()) Select.clear();
break;
case 'Enter':
var sel = Select.nodes();
if (sel.length === 1) {
e.preventDefault();
Canvas.Edit.open(sel[0]);
}
break;
case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break;
case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break;
case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break;
case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break;
case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break;
case 'f': Canvas.Actions.addFile(); break;
case 'l': Canvas.Actions.addLink(); break;
case 'g': Canvas.Actions.addGroup(); break;
case '9': View.fit(Doc.nodes()); break;
case '0': View.resetZoom(); break;
case '+':
case '=': View.zoomBy(1.2); break;
case '-': View.zoomBy(1 / 1.2); break;
}
});
}
// --- boot ---
Doc.init(data.doc, data.hash);
Nodes.seed(data.rendered, Doc.get());
Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups'));
Edges.init(document.getElementById('canvas-edges'));
View.init(surface, world);
Canvas.Interact.init(surface, marquee);
Save.init(data.postUrl, data.name);
Doc.onChange(refresh);
Select.onChange(function () {
Nodes.syncSelection();
Edges.syncSelection();
updateToolbar();
});
View.onChange(updateZoom);
Save.onState(function (state, message) { setStatus(message); });
wireToolbar();
wireKeys();
refresh();
View.fit(Doc.nodes());
updateZoom();
surface.focus();
})();
-20
View File
@@ -1,20 +0,0 @@
{{/* 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}}
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
// save.js — writing the canvas back.
//
// The whole document goes up in one request: a canvas is a single JSON object
// whose edges reference node ids, so a partial patch would have to reason about
// cross-references the server would then have to re-validate anyway.
//
// Two server rejections are worth handling rather than dumping on the user:
// a 409 (someone else changed the file) and the empty-canvas guard (which asks
// for explicit confirmation before letting a save blank a populated canvas).
window.Canvas = window.Canvas || {};
Canvas.Save = (function () {
var Doc = Canvas.Doc;
var action = '';
var saving = false;
var listeners = [];
function emit(state, message) {
for (var i = 0; i < listeners.length; i++) listeners[i](state, message);
}
function post(url) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Canvas-Version': Doc.version()
},
body: JSON.stringify(Doc.get())
});
}
function run(force) {
if (saving) return Promise.resolve(false);
if (!Doc.isDirty() && !force) {
emit('clean', 'no changes');
return Promise.resolve(true);
}
saving = true;
emit('saving', 'saving…');
var url = action + (force ? '&empty=1' : '');
return post(url).then(function (res) {
if (res.status === 204) {
Doc.markSaved(res.headers.get('X-Canvas-Version') || '');
emit('clean', 'saved');
return true;
}
return res.text().then(function (msg) {
if (res.status === 409) {
conflict(msg);
} else if (res.status === 400 && msg.indexOf('empty canvas') !== -1) {
confirmEmpty(msg);
} else {
emit('error', msg || ('save failed (' + res.status + ')'));
alert(msg || ('Save failed (' + res.status + ')'));
}
return false;
});
}).catch(function () {
emit('error', 'network error');
alert('Could not reach the server — your changes are still here, try again.');
return false;
}).finally(function () {
saving = false;
});
}
// conflict: the file changed underneath us. Overwriting silently would
// discard whatever the other writer did, so the choice is the user's — and
// reloading would discard *their* work, so that is spelled out too.
function conflict(msg) {
emit('error', 'conflict');
openModal({
title: 'Canvas changed on disk',
body: msg + '\n\nReload to take the version on disk (your unsaved changes here are lost), ' +
'or keep editing and save again to overwrite it.',
confirm: {
label: 'RELOAD',
danger: true,
onConfirm: function () {
closeModal();
window.location.reload();
}
},
cancel: { label: 'KEEP EDITING', autofocus: true }
});
}
function confirmEmpty(msg) {
emit('error', 'refused');
openModal({
title: 'Save an empty canvas?',
body: msg + '\n\nThis removes every node from the file.',
confirm: {
label: 'EMPTY IT',
danger: true,
onConfirm: function () {
closeModal();
run(true);
}
}
});
}
return {
init: function (postURL, name) {
action = postURL + '?canvas=' + encodeURIComponent(name);
// Unsaved work must not vanish on a stray Back or tab close. The
// browser shows its own generic wording; the return value is only a
// signal that there is something to lose.
window.addEventListener('beforeunload', function (e) {
if (!Doc.isDirty()) return;
e.preventDefault();
e.returnValue = '';
});
},
run: function () { return run(false); },
isSaving: function () { return saving; },
onState: function (fn) { listeners.push(fn); }
};
})();
+77
View File
@@ -0,0 +1,77 @@
// select.js — which nodes and edges are selected.
//
// Kept apart from the document so selection never lands in the undo stack or
// on disk: undoing an edit should restore geometry, not re-select whatever
// happened to be highlighted at the time.
window.Canvas = window.Canvas || {};
Canvas.Select = (function () {
var nodes = {};
var edges = {};
var listeners = [];
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function keys(obj) { return Object.keys(obj); }
return {
nodes: function () { return keys(nodes); },
edges: function () { return keys(edges); },
hasNode: function (id) { return !!nodes[id]; },
hasEdge: function (id) { return !!edges[id]; },
isEmpty: function () { return !keys(nodes).length && !keys(edges).length; },
count: function () { return keys(nodes).length + keys(edges).length; },
set: function (nodeIds, edgeIds) {
nodes = {};
edges = {};
(nodeIds || []).forEach(function (id) { nodes[id] = true; });
(edgeIds || []).forEach(function (id) { edges[id] = true; });
emit();
},
addNode: function (id) {
if (nodes[id]) return;
nodes[id] = true;
emit();
},
toggleNode: function (id) {
if (nodes[id]) delete nodes[id]; else nodes[id] = true;
emit();
},
toggleEdge: function (id) {
if (edges[id]) delete edges[id]; else edges[id] = true;
emit();
},
clear: function () {
if (!keys(nodes).length && !keys(edges).length) return;
nodes = {};
edges = {};
emit();
},
// prune drops ids that no longer exist. Called after undo/redo, where
// the document can lose nodes the selection still refers to.
prune: function (doc) {
var changed = false;
var live = {};
doc.nodes.forEach(function (n) { live[n.id] = true; });
keys(nodes).forEach(function (id) {
if (!live[id]) { delete nodes[id]; changed = true; }
});
var liveEdges = {};
doc.edges.forEach(function (e) { liveEdges[e.id] = true; });
keys(edges).forEach(function (id) {
if (!liveEdges[id]) { delete edges[id]; changed = true; }
});
if (changed) emit();
},
onChange: function (fn) { listeners.push(fn); }
};
})();
+147
View File
@@ -0,0 +1,147 @@
// view.js — pan and zoom.
//
// The whole scene lives in one transformed <div>, so panning and zooming cost a
// single CSS transform: no relayout, no re-render of node content, nothing that
// touches the DOM per node. That is what keeps a few hundred nodes usable on
// the mobile/VPN path.
//
// The dot grid is painted on the (untransformed) surface and its size/offset
// follow the transform, which keeps it crisp at every zoom level instead of
// scaling a bitmap.
window.Canvas = window.Canvas || {};
Canvas.View = (function () {
var MIN_SCALE = 0.05;
var MAX_SCALE = 4;
var GRID = 20;
var surface = null;
var world = null;
var tx = 0, ty = 0, scale = 1;
var listeners = [];
function apply() {
world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
var step = GRID * scale;
surface.style.backgroundSize = step + 'px ' + step + 'px';
surface.style.backgroundPosition = tx + 'px ' + ty + 'px';
// Below a few pixels apart the dots stop reading as a grid and start
// reading as noise, so zooming far out drops them entirely.
surface.classList.toggle('is-grid-hidden', step < 6);
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function clampScale(s) {
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
}
// zoomAt scales about a screen point, so the world coordinate under the
// cursor stays put — the behaviour that makes wheel-zoom feel anchored
// rather than lurching toward the origin.
function zoomAt(clientX, clientY, factor) {
var next = clampScale(scale * factor);
if (next === scale) return;
var r = surface.getBoundingClientRect();
var px = clientX - r.left;
var py = clientY - r.top;
var ratio = next / scale;
tx = px - (px - tx) * ratio;
ty = py - (py - ty) * ratio;
scale = next;
apply();
}
function bounds(nodes) {
if (!nodes.length) return null;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(function (n) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
});
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
return {
GRID: GRID,
init: function (surfaceEl, worldEl) {
surface = surfaceEl;
world = worldEl;
apply();
},
get: function () { return { x: tx, y: ty, scale: scale }; },
scale: function () { return scale; },
toWorld: function (clientX, clientY) {
var r = surface.getBoundingClientRect();
return {
x: (clientX - r.left - tx) / scale,
y: (clientY - r.top - ty) / scale
};
},
toScreen: function (x, y) {
var r = surface.getBoundingClientRect();
return { x: x * scale + tx + r.left, y: y * scale + ty + r.top };
},
pan: function (dx, dy) {
tx += dx;
ty += dy;
apply();
},
zoomAt: zoomAt,
// zoomBy scales about the viewport centre, for the toolbar buttons and
// the +/- shortcuts where there is no cursor to anchor to.
zoomBy: function (factor) {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor);
},
resetZoom: function () {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale);
},
// fit frames the whole document with a margin, never zooming past 1:1 —
// a canvas with three small nodes should not open comically magnified.
fit: function (nodes) {
var b = bounds(nodes);
var r = surface.getBoundingClientRect();
if (!b || !r.width || !r.height) {
tx = 0; ty = 0; scale = 1;
apply();
return;
}
var pad = 60;
var s = clampScale(Math.min(
(r.width - pad * 2) / Math.max(b.width, 1),
(r.height - pad * 2) / Math.max(b.height, 1),
1
));
scale = s;
tx = r.width / 2 - (b.x + b.width / 2) * s;
ty = r.height / 2 - (b.y + b.height / 2) * s;
apply();
},
// centreOfViewport is where a newly added node goes: wherever the user
// is currently looking.
centreOfViewport: function () {
var r = surface.getBoundingClientRect();
return {
x: (r.width / 2 - tx) / scale,
y: (r.height / 2 - ty) / scale
};
},
bounds: bounds,
onChange: function (fn) { listeners.push(fn); },
refresh: apply
};
})();
+2 -11
View File
@@ -132,8 +132,6 @@
// Folders end with "/" — let the browser navigate normally. // Folders end with "/" — let the browser navigate normally.
var path = (item && item.dataset.path) || anchor.getAttribute('href'); var path = (item && item.dataset.path) || anchor.getAttribute('href');
if (!path || path.endsWith('/')) return; 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. // Allow modified clicks (open in new tab, etc.) to pass through.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var rel = wikiPathFromHref(path); var rel = wikiPathFromHref(path);
@@ -202,18 +200,11 @@
// leaves the status icon and reveal buttons invisible on mobile. // leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return; if (isMobileDevice()) return;
probeStatus().then(function () { probeStatus().then(function () {
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(); updateFooterIcon();
wireFileLinks();
wireRevealButton(); wireRevealButton();
wireFileRevealButtons(); wireFileRevealButtons();
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+1 -1
View File
@@ -24,4 +24,4 @@
</table> </table>
{{end}} {{end}}
</div> </div>
<script src="{{asset "diary/calendar.js"}}"></script> <script src="/_/diary/calendar.js"></script>
-4
View File
@@ -10,8 +10,4 @@
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
{{if or .Events .Weight}}<div class="diary-day-cal muted">
{{if .Events}}<p>{{.Events}}</p>{{end}}
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
</div>{{end}}
{{end}} {{end}}
-434
View File
@@ -1,434 +0,0 @@
// 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 };
})();
+70 -8
View File
@@ -11,15 +11,77 @@
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}} {{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if .Span}}<input type="hidden" name="span" value="1">{{end}} {{if .Span}}<input type="hidden" name="span" value="1">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}} {{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
{{template "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>
<div id="editor" class="editor-cm"></div> <div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea> <textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form> </form>
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script> <script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="{{asset "editor/tables.js"}}"></script> <script src="/_/editor/tables.js"></script>
<script src="{{asset "editor/dates.js"}}"></script> <script src="/_/editor/dates.js"></script>
<script src="{{asset "editor/movie.js"}}"></script> <script src="/_/editor/movie.js"></script>
<script src="{{asset "editor/wikicomplete.js"}}"></script> <script src="/_/editor/wikicomplete.js"></script>
<script src="{{asset "editor/core.js"}}"></script> <script src="/_/editor/main.js"></script>
<script src="{{asset "editor/main.js"}}"></script>
{{end}} {{end}}
+394 -7
View File
@@ -1,12 +1,161 @@
// The page editor: one markdown editor (core.js) inside the edit form, saved
// with the header's SAVE button.
(function () { (function () {
var mount = document.getElementById('editor'); var mount = document.getElementById('editor');
var hidden = document.getElementById('editor-content'); var hidden = document.getElementById('editor-content');
if (!mount || !hidden || !window.CM) return; if (!mount || !hidden || !window.CM) return;
var form = hidden.closest('form'); var form = hidden.closest('form');
var editor = MarkdownEditor.create(mount, { doc: hidden.value });
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();
},
};
// --- Content sync --- // --- Content sync ---
@@ -14,7 +163,7 @@
// must not depend on CM focus or async state (decision 8). requestSubmit // must not depend on CM focus or async state (decision 8). requestSubmit
// fires this listener even for the ALT+SHIFT+S path. // fires this listener even for the ALT+SHIFT+S path.
function syncContent() { function syncContent() {
hidden.value = editor.getValue(); hidden.value = view.state.doc.toString();
} }
// Save POSTs via fetch and then rewrites the *editor's* history entry with // Save POSTs via fetch and then rewrites the *editor's* history entry with
@@ -59,8 +208,246 @@
// --- Actions --- // --- Actions ---
editor.actions.save = function () { form.requestSubmit(); }; 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,
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); },
};
// The toolbar and the header SAVE button both name actions. // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
MarkdownEditor.bind(document.body, function () { return editor.actions; }); // 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 = decodeURIComponent(window.location.pathname);
if (base.slice(-1) !== '/') base += '/';
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 = decodeURIComponent(window.location.pathname).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
));
}
}
});
}
});
}
// --- 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); });
});
})(); })();
+10 -23
View File
@@ -6,34 +6,22 @@ window.EditorTables = (function () {
return s; return s;
} }
var segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
// Graphemes rendered two columns wide: emoji-presentation characters, anything
// forced to emoji presentation via VS16, and East Asian wide/fullwidth ranges.
var WIDE = /\p{Emoji_Presentation}|️|[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
var ZERO = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
function displayWidth(s) {
var w = 0;
Array.from(segmenter.segment(s), function (seg) {
var g = seg.segment;
if (!ZERO.test(g)) w += WIDE.test(g) ? 2 : 1;
});
return w;
}
function padLeft(s, w) { function padLeft(s, w) {
return repeat(' ', w - displayWidth(s)) + s; while (s.length < w) s = ' ' + s;
return s;
} }
function padRight(s, w) { function padRight(s, w) {
return s + repeat(' ', w - displayWidth(s)); while (s.length < w) s += ' ';
return s;
} }
function padCenter(s, w) { function padCenter(s, w) {
var total = w - displayWidth(s); while (s.length < w) {
var left = Math.floor(total / 2); s = s + ' ';
return repeat(' ', left) + s + repeat(' ', total - left); if (s.length < w) s = ' ' + s;
}
return s;
} }
function parseTableRow(line) { function parseTableRow(line) {
@@ -113,8 +101,7 @@ window.EditorTables = (function () {
var max = 3; var max = 3;
for (var r = 0; r < rows.length; r++) { for (var r = 0; r < rows.length; r++) {
if (r === sepIndex) continue; if (r === sepIndex) continue;
var len = displayWidth(rows[r][c]); if (rows[r][c].length > max) max = rows[r][c].length;
if (len > max) max = len;
} }
widths.push(max); widths.push(max);
} }
-67
View File
@@ -1,67 +0,0 @@
{{/* 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}}
+9 -63
View File
@@ -1,64 +1,10 @@
// Fitness dashboard chart hover. The range dropdowns and "absolute" // Fitness dashboard range dropdowns: changing one reloads the page with that
// checkboxes are htmx (assets/fitness/main.html): they swap in only their // chart's query parameter updated. Plain GET navigation — each range is a
// chart. // distinct, bookmarkable view, so no postReplace/history handling is needed.
// Boosted navigation re-runs this script on every visit; the listeners document.addEventListener('change', function (e) {
// are delegated to document, so register them once. var sel = e.target.closest('[data-fitness-range]');
if (!window.fitnessWired) { if (!sel) return;
window.fitnessWired = true; var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots window.location.href = url.toString();
// 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';
}); });
}
+5 -14
View File
@@ -1,26 +1,20 @@
{{/* 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"}} {{define "fitnessChart"}}
<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"> <section class="fitness-chart panel">
<div class="fitness-chart-header row space-between"> <div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span> <span class="caption">{{.Title}}</span>
<div class="row fitness-controls"> <select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
<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}} {{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select> </select>
</div> </div>
</div>
{{if .Empty}} {{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p> <p class="fitness-empty is-empty">No data in this range.</p>
{{else}} {{else}}
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}"> <svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}} {{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/> <line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text> <text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}} {{end}}
{{range .XTicks}} {{range .XTicks}}
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text> <text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}} {{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/> <line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -29,16 +23,13 @@ sending every chart's controls so the server can update the page URL. */}}
<polyline class="chart-line" points="{{.}}"/> <polyline class="chart-line" points="{{.}}"/>
{{end}} {{end}}
{{range .Dots}} {{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/> <circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
{{end}} {{end}}
{{if .Goal}} {{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/> <line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text> <text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}} {{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg> </svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
@@ -49,4 +40,4 @@ sending every chart's controls so the server can update the page URL. */}}
{{range .Charts}}{{template "fitnessChart" .}}{{end}} {{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}} {{end}}
</div> </div>
<script src="{{asset "fitness/fitness.js"}}"></script> <script src="/_/fitness/fitness.js"></script>
+1 -9
View File
@@ -16,12 +16,6 @@
e.preventDefault(); e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage(); if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break; break;
case 'H':
var maint = document.querySelector('[data-action="maint-menu"]');
if (!maint) return;
e.preventDefault();
maint.click();
break;
case 'F': case 'F':
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (!input) return; if (!input) return;
@@ -64,8 +58,7 @@ document.addEventListener('keydown', function (e) {
}); });
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
['actions-menu', 'maint-menu'].forEach(function (action) { var trigger = document.querySelector('[data-action="actions-menu"]');
var trigger = document.querySelector('[data-action="' + action + '"]');
if (!trigger) return; if (!trigger) return;
wireDropdown(trigger); wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu'); var menu = trigger.parentElement.querySelector('.dropdown-menu');
@@ -73,5 +66,4 @@ document.addEventListener('DOMContentLoaded', function () {
trigger.addEventListener('click', function () { trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false); trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
}); });
});
}); });
-141
View File
@@ -1,141 +0,0 @@
// 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'));
});
})();
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" <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"> fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
<path d="M1 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/> <path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 249 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 259 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 241 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 260 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 240 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 228 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 246 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 252 B

+28 -69
View File
@@ -4,87 +4,48 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="icon" href="{{asset "favicon.ico"}}" /> <link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="{{asset "style.css"}}" /> <link rel="stylesheet" href="/_/style.css" />
<script src="{{asset "vendor/htmx.min.js"}}" defer></script> <script src="/_/modal.js"></script>
<script src="{{asset "hx.js"}}" defer></script> <script src="/_/global-shortcuts.js"></script>
<script src="{{asset "modal.js"}}"></script> <script src="/_/history-nav.js"></script>
<script src="{{asset "global-shortcuts.js"}}"></script> <script src="/_/scroll-sync.js" defer></script>
<script src="{{asset "history-nav.js"}}"></script> <script src="/_/search-suggest.js" defer></script>
<script src="{{asset "scroll-sync.js"}}" defer></script> <script src="/_/tree-picker.js"></script>
<script src="{{asset "search-suggest.js"}}" defer></script> <script src="/_/companion.js" defer></script>
<script src="{{asset "tree-picker.js"}}"></script> {{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
<script src="{{asset "companion.js"}}" defer></script> <script src="/_/tree-sidebar.js" defer></script>{{end}}
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script> {{block "headScripts" .}}{{end}}
<script src="{{asset "page/actions.js"}}"></script>
<script src="{{asset "search/actions.js"}}"></script>{{end}}
</head> </head>
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell <body>
(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> <header>
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
</nav> </nav>
{{if not .EditMode}} {{if .ShowPageChrome}}
<form class="search-form" action="/" method="get"> <form class="search-form" action="/" method="get">
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form> </form>
{{end}} {{end}}
<div class="header-actions row"> <div class="header-actions row">
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}} {{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
{{maintenanceWidget}}
<span class="dropdown actions"> <span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button> <button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu"> <div class="dropdown-menu align-right actions-menu">
{{template "page-actions" .}} <div class="actions-special"></div>
{{template "headerActions" . }}
</div> </div>
</span> </span>
{{end}} {{end}}
</div> </div>
</header> </header>
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div> <div class="shell">
{{block "extras" .}}{{end}} {{if .ShowPageChrome}}<aside class="tree-sidebar col">
<div id="modal-host"></div> <div class="tree-scroll"></div>
</body> </aside>{{end}}
</html> <div class="center">
{{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> <main>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
@@ -96,12 +57,10 @@
<div class="dropdown-menu align-right open-up companion-flyout"></div> <div class="dropdown-menu align-right open-up companion-flyout"></div>
</span> </span>
</footer> </footer>
</div> </div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}} {{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}}
</body>
</html>
{{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}}
+113 -121
View File
@@ -1,44 +1,65 @@
// One modal at a time, from either of two sources sharing the same chrome
// (backdrop click, Escape, drag by the header, focus restore):
// - openModal(opts) builds a modal from JS;
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
// #modal-host. Their forms post themselves; a [data-modal-close] element
// closes them.
(function () { (function () {
var current = null; // { backdrop, prevFocus, onKeydown } var backdrop = null;
var drag = null; // { modal, startX, startY, originX, originY } 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;
function build() {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
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');
header.appendChild(titleEl);
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);
// 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) { backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close(); if (e.target === backdrop) close();
}); });
backdrop.addEventListener('click', function (e) { wireDrag(header);
if (e.target.closest('[data-modal-close]')) close(); cancelBtn.addEventListener('click', close);
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
currentOpts.confirm.onConfirm();
}
}); });
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 // wireDrag makes the modal draggable by `handle`. Dragging switches the
// doesn't fight us. // modal to fixed positioning so flexbox alignment doesn't fight us.
function startDrag(e, modal) { function wireDrag(handle) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return; if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e; var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect(); var rect = modal.getBoundingClientRect();
@@ -46,66 +67,54 @@
modal.style.position = 'fixed'; modal.style.position = 'fixed';
modal.style.left = rect.left + 'px'; modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px'; modal.style.top = rect.top + 'px';
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top }; dragging = true;
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault(); e.preventDefault();
} }
function onMove(e) { function onMove(e) {
if (!drag) return; if (!dragging) return;
var pt = e.touches ? e.touches[0] : e; var pt = e.touches ? e.touches[0] : e;
var m = drag.modal; var dx = pt.clientX - startX;
var maxX = window.innerWidth - m.offsetWidth; var dy = pt.clientY - startY;
var maxY = window.innerHeight - m.offsetHeight; var w = modal.offsetWidth;
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px'; var h = modal.offsetHeight;
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px'; 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() { drag = null; } function onUp() { dragging = false; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', 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) { function open(opts) {
var prevFocus = current ? detach() : document.activeElement; if (backdrop && backdrop.parentNode) close();
if (!backdrop) build();
var backdrop = document.createElement('div'); currentOpts = opts;
backdrop.className = 'modal-backdrop'; prevFocus = document.activeElement;
var modal = document.createElement('div');
modal.className = 'modal panel panel-floating'; modal.classList.remove('is-dragged');
modal.setAttribute('role', 'dialog'); modal.style.position = '';
modal.setAttribute('aria-modal', 'true'); modal.style.left = '';
modal.style.top = '';
var header = document.createElement('div');
header.className = 'panel-header';
var titleEl = document.createElement('span');
titleEl.textContent = opts.title || ''; titleEl.textContent = opts.title || '';
header.appendChild(titleEl);
var bodyEl = document.createElement('div'); bodyEl.textContent = '';
bodyEl.className = 'panel-body';
if (opts.body instanceof Node) { if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body); bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') { } else if (typeof opts.body === 'string') {
@@ -115,24 +124,14 @@
var confirmOpts = opts.confirm || {}; var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {}; var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK'; confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : ''); confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
confirmBtn.disabled = !!confirmOpts.initiallyDisabled; confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = cancelOpts.label || 'CANCEL'; cancelBtn.textContent = cancelOpts.label || 'CANCEL';
cancelBtn.className = 'btn'; cancelBtn.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
var footerEl = document.createElement('div'); footerEl.textContent = '';
footerEl.className = 'panel-footer';
if (opts.swapButtons) { if (opts.swapButtons) {
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn); footerEl.appendChild(cancelBtn);
@@ -141,20 +140,7 @@
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
} }
modal.appendChild(header); document.body.appendChild(backdrop);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
var enterConfirms = confirmOpts.enterConfirms !== false;
show(backdrop, document.body, prevFocus, function (e) {
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
});
setTimeout(function () { setTimeout(function () {
if (cancelOpts.autofocus) { if (cancelOpts.autofocus) {
@@ -170,6 +156,22 @@
} }
}, 0); }, 0);
var enterConfirms = confirmOpts.enterConfirms !== false;
onKeydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
};
document.addEventListener('keydown', onKeydown);
return { return {
close: close, close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; }, setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
@@ -177,30 +179,20 @@
}; };
} }
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal function close() {
// is open (e.g. the merge step replacing the move dialog); focus returns if (!backdrop || !backdrop.parentNode) return;
// to where it was before the first one opened. if (onKeydown) {
var hostPrevFocus = null; document.removeEventListener('keydown', onKeydown);
// History restores swap without event details; they never target the host. onKeydown = null;
function isHost(e) { }
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host'); backdrop.parentNode.removeChild(backdrop);
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
} }
document.addEventListener('htmx:beforeSwap', function (e) {
if (!isHost(e)) return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
if (!isHost(e)) return;
var host = e.detail.target;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
if (f) {
f.focus();
if (f.select) f.select();
} }
});
window.openModal = open; window.openModal = open;
window.closeModal = close; window.closeModal = close;
+115 -6
View File
@@ -3,6 +3,40 @@ function encodePickedPath(p) {
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
} }
// postReplace POSTs to action with the optional form body, then loads target
// into the current history entry — so the action and its result occupy one
// entry instead of two, and back-navigation skips past the stale pre-mutation
// snapshot in bfcache. body may be null for empty POSTs.
//
// We can't just call window.location.replace(target): when target differs from
// the current URL only by fragment, the browser updates the URL bar without
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' };
if (body) {
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = body;
}
fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function promptPageName(title, initial, confirmLabel, onName) { function promptPageName(title, initial, confirmLabel, onName) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
@@ -41,10 +75,45 @@ function newPage() {
}); });
} }
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() { function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return; if (!current) return;
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/'); var segs = current.split('/').filter(Boolean);
var currentName = segs[segs.length - 1] || '';
var parent = '/' + segs.slice(0, -1).join('/');
if (parent === '/') parent = '/';
openTreePicker({ openTreePicker({
title: 'Move — new parent?', title: 'Move — new parent?',
@@ -54,13 +123,53 @@ function movePage() {
hideFiles: true, hideFiles: true,
confirmLabel: 'NEXT', confirmLabel: 'NEXT',
onSelect: function (newParent) { onSelect: function (newParent) {
// The server renders the name step and handles what follows var input = document.createElement('input');
// (merge confirmation, errors, redirect). input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = currentName;
openModal({
title: 'Move — new name?',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
var target = encodePickedPath(dest) + '/';
closeModal(); closeModal();
htmx.ajax('GET', window.location.pathname + '?dialog=move', { submitMove(action, target);
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
});
}
+43
View File
@@ -0,0 +1,43 @@
// canvas-actions.js — creating a canvas from a normal page.
//
// The canvas view itself owns everything else (rename, delete, editing); this
// only covers the one action that has to start from the page, since a folder
// with no canvases has no canvas view to reach.
//
// Reuses openModal and postReplace from page/actions.js, which the page
// template loads first.
// validCanvasName mirrors canvasNameRe in canvas.go. Checking here only buys a
// better error message — the server rejects the same set regardless.
function validCanvasName(name) {
if (!name || name.length > 100) return false;
if (name.charAt(0) === '.') return false;
return !/[\/\\]/.test(name);
}
function newCanvas() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Canvas name';
openModal({
title: 'New canvas',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var name = input.value.trim();
if (!validCanvasName(name)) {
alert('A canvas name cannot be empty, start with a dot, or contain a slash.');
return;
}
closeModal();
var base = window.location.pathname;
if (base.slice(-1) !== '/') base += '/';
var target = base + '?canvas=' + encodeURIComponent(name);
postReplace(target + '&create=1', null, target);
}
}
});
}
+2 -8
View File
@@ -1,7 +1,4 @@
// Runs on DOMContentLoaded for a full page load, and right away when this document.addEventListener("DOMContentLoaded", function () {
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var aside = document.querySelector("aside.sidebar"); var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special"); var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return; if (!aside || !aside.children.length || !specials) return;
@@ -15,7 +12,4 @@
if (typeof openOverlay === "function") openOverlay(aside); if (typeof openOverlay === "function") openOverlay(aside);
}); });
specials.appendChild(item); specials.appendChild(item);
} });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
-93
View File
@@ -1,93 +0,0 @@
{{/* 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}}
+30 -14
View File
@@ -1,26 +1,36 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>
<script src="/_/page/canvas-actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}} {{define "headerActions"}}{{if .CanEdit}}
<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" onclick="newPage()" title="New page (N)">NEW</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">REVEAL</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}} {{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button> <button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</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> <button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}} {{end}}
<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> <a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
{{end}}{{end}} {{end}}{{end}}
{{define "content"}} {{define "content"}}
{{/* Canvases in this folder are alternative views of the same page, so they
read as a tab strip above the content rather than as rows in the file
listing (listEntries filters them out for exactly this reason). */}}
{{if .Canvases}}
<nav class="canvas-tabs row" aria-label="Page views">
<span class="canvas-tab is-active">PAGE</span>
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
</nav>
{{end}}
{{if .Content}} {{if .Content}}
<div class="content">{{.Content}}</div> <div class="content">{{.Content}}</div>
{{end}} {{end}}
{{if .SpecialContent}} {{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div> <div class="content">{{.SpecialContent}}</div>
{{end}} {{end}}
{{if not (or .Content .SpecialContent)}}
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
{{end}}
{{if .Entries}} {{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" hx-get="?dialog=view" hx-target="#modal-host" title="View &amp; sorting">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" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
{{if eq .View "thumbnail"}} {{if eq .View "thumbnail"}}
<div class="thumb-grid"> <div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
@@ -43,15 +53,21 @@
</tbody> </tbody>
</table> </table>
{{end}} {{end}}
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
{{else if not .Content}}
{{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
{{end}}
{{end}} {{end}}
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="{{asset "page/content.js"}}"></script> <script src="/_/page/content.js"></script>
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}} {{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
<script src="{{asset "page/sections.js"}}"></script> <script src="/_/page/sections.js"></script>
{{end}} {{end}}
<script src="{{asset "page/contents.js"}}"></script> <script src="/_/page/contents.js"></script>
{{end}} {{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}} {{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
+24
View File
@@ -0,0 +1,24 @@
(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;
});
});
});
})();
+2 -8
View File
@@ -1,7 +1,4 @@
// Runs on DOMContentLoaded for a full page load, and right away when this document.addEventListener("DOMContentLoaded", function () {
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var content = document.querySelector("main"); var content = document.querySelector("main");
if (!content) return; if (!content) return;
@@ -32,7 +29,4 @@
nav.appendChild(list); nav.appendChild(list);
content.insertBefore(nav, content.firstChild); content.insertBefore(nav, content.firstChild);
} });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+85
View File
@@ -0,0 +1,85 @@
// View-settings modal: lets the user pick the folder listing's view style,
// sort key, and order, then persists them by POSTing to the folder with
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
function openViewSettings() {
var btn = document.getElementById('view-settings-btn');
var state = {
view: (btn && btn.dataset.view) || 'list',
sort: (btn && btn.dataset.sort) || 'name',
order: (btn && btn.dataset.order) || 'asc'
};
// segmented builds a row of mutually-exclusive .btn toggles bound to a
// single state key, marking the current choice with .is-active.
function segmented(key, options) {
var wrap = document.createElement('div');
wrap.className = 'row gap-1';
options.forEach(function (opt) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () {
state[key] = opt.value;
wrap.querySelectorAll('button').forEach(function (x) {
x.classList.remove('is-active');
});
b.classList.add('is-active');
});
wrap.appendChild(b);
});
return wrap;
}
function field(labelText, control) {
var row = document.createElement('div');
row.className = 'col gap-1';
var label = document.createElement('span');
label.className = 'caption';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
var sortSelect = document.createElement('select');
sortSelect.className = 'input';
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
if (state.sort === o[0]) opt.selected = true;
sortSelect.appendChild(opt);
});
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
var body = document.createElement('div');
body.className = 'col';
body.appendChild(field('View style', segmented('view', [
{ value: 'list', label: 'List' },
{ value: 'thumbnail', label: 'Thumbnail' }
])));
body.appendChild(field('Sort by', sortSelect));
body.appendChild(field('Order', segmented('order', [
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' }
])));
openModal({
title: 'View settings',
body: body,
confirm: {
label: 'SAVE',
onConfirm: function () {
var action = window.location.pathname + '?settings';
var formBody = 'view=' + encodeURIComponent(state.view) +
'&sort=' + encodeURIComponent(state.sort) +
'&order=' + encodeURIComponent(state.order);
var target = window.location.pathname;
closeModal();
postReplace(action, formBody, target);
}
}
});
}
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Save link</title> <title>Save link</title>
<link rel="icon" href="{{asset "favicon.ico"}}" /> <link rel="icon" href="/_/favicon.ico" />
<link rel="stylesheet" href="{{asset "style.css"}}" /> <link rel="stylesheet" href="/_/style.css" />
<style> <style>
body { padding: 0.8rem; } body { padding: 0.8rem; }
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; } .qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
+5 -10
View File
@@ -45,22 +45,17 @@
} }
// Reading mode: keep the latest scroll fraction recorded (throttled to one // Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens. Boosted // write per frame) so it is ready the moment the editor opens.
// navigation replaces .center and changes the page without a reload, so
// listen on document and resolve both when writing.
var pending = false; var pending = false;
document.addEventListener('scroll', function (e) { center.addEventListener('scroll', function () {
if (pending || !e.target.classList || !e.target.classList.contains('center')) return; if (pending) return;
pending = true; pending = true;
requestAnimationFrame(function () { requestAnimationFrame(function () {
pending = false; pending = false;
center = document.querySelector('.center');
if (!center) return;
var m = maxScroll(); var m = maxScroll();
try { try {
sessionStorage.setItem('scrollsync:' + location.pathname, sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {} } catch (e) {}
}); });
}, { capture: true, passive: true }); }, { passive: true });
})(); })();
+246 -73
View File
@@ -1,18 +1,63 @@
// search-suggest.js — instant typeahead dropdown. // search-suggest.js — instant typeahead dropdown.
// //
// Exposes window.attachSuggestions(inputEl, opts) used by both the header // Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Fetching is htmx: the input // search box and the editor's "Insert link" modal. Owns: debounced fetching,
// requests /_search as you type (debounced; a newer request replaces an // request ordering, DOM creation, keyboard handling, open/close lifecycle.
// in-flight one) and the server renders the rows (assets/search/suggest.html).
// This file owns the dropdown's open/close lifecycle and keyboard handling.
// //
// opts: // opts:
// onPick({name}) — optional; called when the user selects a row. // onPick(result) — called when the user selects a row
// Without it rows are plain links and navigate. // onShowAll(query) — optional; called when the footer row activates
// showFooter (bool) — show the "Show all N matches" footer row // showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode) // container (Element) — optional parent (defaults to inputEl.parentNode)
(function () { (function () {
var seq = 0; 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;
}
function attachSuggestions(inputEl, opts) { function attachSuggestions(inputEl, opts) {
if (!inputEl) return; if (!inputEl) return;
@@ -23,116 +68,237 @@
var dropdown = document.createElement('div'); var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown'; dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown); host.appendChild(dropdown);
// htmx sends the input's value under its name. function makeRow(cls, tabbable) {
if (!inputEl.name) inputEl.name = 'q'; var tr = document.createElement('tr');
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : '')); tr.className = cls;
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms'); if (tabbable) tr.setAttribute('tabindex', '0');
inputEl.setAttribute('hx-sync', 'this:replace'); var td = document.createElement('td');
inputEl.setAttribute('hx-target', '#' + dropdown.id); tr.appendChild(td);
htmx.process(inputEl); return { tr: tr, td: td };
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
} }
function isOpen() { var state = {
return dropdown.classList.contains('is-open'); 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 open() { function open() {
if (dropdown.firstElementChild) dropdown.classList.add('is-open'); state.open = true;
render();
} }
function close() { function close() {
dropdown.classList.remove('is-open'); state.open = false;
setActive(-1); state.activeIdx = -1;
render();
} }
function setActive(idx) { function fetchResults(query) {
var rs = rows(); var seq = ++state.reqSeq;
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active'); fetch('/_search?q=' + encodeURIComponent(query), {
activeIdx = idx; credentials: 'same-origin',
if (rs[idx]) { headers: { 'Accept': 'application/json' },
rs[idx].classList.add('is-active'); }).then(function (r) {
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {} 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;
}
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
} }
function moveActive(delta) { function moveActive(delta) {
var n = rows().length; var n = rowCount();
if (n === 0) return; if (n === 0) return;
var next = activeIdx + delta; var next = state.activeIdx + delta;
if (next < 0) next = n - 1; if (next < 0) next = n - 1;
if (next >= n) next = 0; if (next >= n) next = 0;
setActive(next); state.activeIdx = next;
} render();
// Keep the active row in view.
// pick activates a row: onPick for data rows when given, otherwise var active = dropdown.querySelector('tr.is-active');
// (and always for the footer) the row's link. if (active && active.scrollIntoView) {
function pick(row) { try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
close();
if (opts.onPick && !row.classList.contains('suggest-footer')) {
opts.onPick({ name: row.dataset.name });
} else {
row.querySelector('a').click();
} }
} }
dropdown.addEventListener('htmx:afterSwap', function () { function activateCurrent() {
activeIdx = -1; if (state.activeIdx < 0) return false;
if (document.activeElement === inputEl && dropdown.firstElementChild) { 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)) {
open(); open();
} else {
close();
} }
}); });
inputEl.addEventListener('blur', function () {
// mousedown keeps focus in the input so blur doesn't close the // Delay so click/mousedown on a row still resolves.
// dropdown before the click lands. state.blurTimer = setTimeout(close, 150);
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
dropdown.addEventListener('click', function (e) {
var row = e.target.closest('.suggest-row');
if (!row) return;
if (opts.onPick && !row.classList.contains('suggest-footer')) {
e.preventDefault();
pick(row);
} else {
close(); // the link navigates
}
}); });
inputEl.addEventListener('focus', open);
inputEl.addEventListener('blur', close);
inputEl.addEventListener('keydown', function (e) { inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(1); moveActive(1);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(-1); moveActive(-1);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
close(); close();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
// Without an active row: native submit (full results page). if (state.open && state.activeIdx >= 0) {
var row = rows()[activeIdx];
if (row) {
e.preventDefault(); e.preventDefault();
pick(row); activateCurrent();
} }
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
if (rows().length === 0) return; if (!state.open || rowCount() === 0) return;
e.preventDefault(); e.preventDefault();
moveActive(e.shiftKey ? -1 : 1); moveActive(e.shiftKey ? -1 : 1);
} }
}); });
// Click outside the host closes the dropdown.
document.addEventListener('mousedown', function (e) {
if (!state.open) return;
if (host.contains(e.target)) return;
close();
});
return { return {
close: close, close: close,
destroy: function () { destroy: function () {
@@ -144,7 +310,14 @@
window.attachSuggestions = attachSuggestions; window.attachSuggestions = attachSuggestions;
// Auto-bind to the header search input. Header search submits the form
// for the "show all" action; we route to a navigate-on-pick handler.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true }); var input = document.querySelector('header .search-input');
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
}); });
})(); })();
+10 -19
View File
@@ -52,19 +52,19 @@ function createSearchPage(name) {
}); });
} }
// The "create page" link on results pages; delegated, since boosted document.addEventListener('DOMContentLoaded', function () {
// navigation swaps the results in without a new DOMContentLoaded.
document.addEventListener('click', function (e) { var createLink = document.querySelector('[data-create-page]');
var createLink = e.target.closest && e.target.closest('[data-create-page]'); if (createLink) {
if (!createLink) return; createLink.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page')); createSearchPage(createLink.getAttribute('data-create-page'));
}); });
}
// Focus the search input on results pages so Tab steps directly into the // 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, // first match — the input sits immediately before the results in DOM
// so the natural tab sequence is input → first result → next, … // order, so the natural tab sequence is input → first result → next, …
document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (input && input.value) { if (input && input.value) {
input.focus(); input.focus();
@@ -72,12 +72,3 @@ document.addEventListener('DOMContentLoaded', function () {
try { input.setSelectionRange(end, end); } catch (e) {} try { input.setSelectionRange(end, end); } catch (e) {}
} }
}); });
// The header (and its search box) survives boosted navigation: show the
// current page's query, as a full load would.
document.addEventListener('page:swapped', function () {
var input = document.querySelector('.search-input');
if (input && document.activeElement !== input) {
input.value = new URLSearchParams(location.search).get('q') || '';
}
});
+3 -1
View File
@@ -1,3 +1,5 @@
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}} {{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}} {{define "content"}}
@@ -41,7 +43,7 @@
{{end}} {{end}}
{{define "headerActions"}} {{define "headerActions"}}
<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> <button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
{{end}} {{end}}
{{define "footerExtras"}} {{define "footerExtras"}}
-8
View File
@@ -1,8 +0,0 @@
{{/* 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}}
+339 -427
View File
@@ -26,6 +26,17 @@
--danger-hover: #d03467; --danger-hover: #d03467;
--overlay: rgba(0, 0, 0, 0.55); --overlay: rgba(0, 0, 0, 0.55);
/* JSON Canvas preset colours "1".."6" (red, orange, yellow, green, cyan,
purple), pulled toward the app palette so a canvas does not look like a
different program. Hex colours in a .canvas file pass through untouched. */
--canvas-1: #e05555;
--canvas-2: #e08a3c;
--canvas-3: #d9b03c;
--canvas-4: #5aab64;
--canvas-5: #01b6c4;
--canvas-6: #a069c4;
--canvas-edge: #9a9a9a;
--border: 1px solid var(--secondary); --border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary); --border-dashed: 1px dashed var(--secondary);
--border-guide: 1px solid var(--bg-panel-hover); --border-guide: 1px solid var(--bg-panel-hover);
@@ -46,37 +57,6 @@
The shell itself is edge-to-edge; this keeps prose lines readable on The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */ wide monitors (content stays centered within the center column). */
--reading-width: 60rem; --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 /* Anchor / TOC jumps are handled by the .center scroll container's own
@@ -250,20 +230,6 @@ footer {
.danger { color: var(--danger); } .danger { color: var(--danger); }
.danger:hover { color: var(--danger-hover); } .danger:hover { color: var(--danger-hover); }
.btn.is-active { color: var(--primary-hover); } .btn.is-active { color: var(--primary-hover); }
/* Segmented choice: radios styled as .btn toggles. The label is the button;
the input stays in the accessibility tree and keyboard order. */
.segmented input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.segmented .btn { position: relative; }
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
/* === Form controls === /* === Form controls ===
.input baseline is shared by search-input, modal inputs, and the editor .input baseline is shared by search-input, modal inputs, and the editor
@@ -434,12 +400,6 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; } .dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;} .actions-special > .btn {text-align: left;}
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
.page-actions { display: contents; }
/* Header actions carry an icon and a label: the mobile dropdown shows the
label, the inline desktop bar (see responsive block) the icon. */
.btn-icon { display: none; }
.btn-icon svg { display: block; }
/* === Suggestion dropdown (header search + editor link picker) === /* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -458,19 +418,17 @@ main > h2 {
} }
.suggest-dropdown.is-open { display: block; } .suggest-dropdown.is-open { display: block; }
.suggest-row { cursor: pointer; } .suggest-row { cursor: pointer; }
.suggest-row > td { padding: 0; } .suggest-row > td { padding: 0.4rem 0.6rem; }
/* The link fills the row, so every part of it is the click target. */
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
.suggest-name, .suggest-path { display: block; } .suggest-name, .suggest-path { display: block; }
.suggest-name { color: var(--text); } .suggest-name { color: var(--text); }
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; } .suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
.suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); } .suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
/* === Editor toolbar === /* === Editor toolbar ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
than breaking into stacked rows. A horizontal-scroll container also clips than breaking into stacked rows. A horizontal-scroll container also clips
overflow-y, so open dropdown menus are pinned to the viewport via JS overflow-y, so open dropdown menus are pinned to the viewport via JS
(editor/core.js pinMenu) to escape the clip. */ (editor/main.js pinMenu) to escape the clip. */
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -760,33 +718,17 @@ aside.sidebar:empty { display: none; }
padding: 0.25rem var(--space-1); padding: 0.25rem var(--space-1);
cursor: pointer; cursor: pointer;
} }
.tree-row:hover { background: var(--bg-panel-hover); } .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.is-active > .tree-name { color: var(--secondary); } .tree-row.is-active > .tree-name { color: var(--secondary); }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; } .tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; } .tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; } .tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); } .tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; } .tree-chevron.is-leaf { visibility: hidden; }
/* Expandable folders are <details>; the chevron follows its open state. */
.tree-node > summary { list-style: none; }
.tree-node > summary::-webkit-details-marker { display: none; }
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
/* Picker rows select through a visually hidden radio in the name label. */
.tree-name input[type="radio"] {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.tree-row:has(input:checked) {
background: var(--bg-panel-hover);
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
label.tree-name { flex: 1; cursor: pointer; position: relative; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-children { .tree-children {
margin-left: var(--space-2); margin-left: var(--space-2);
@@ -866,46 +808,13 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
.diary-cal-grid td.cal-current, .diary-cal-grid td.cal-current,
.diary-cal-grid td.cal-current a { color: var(--primary-hover); } .diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { color: var(--primary-hover); } .btn-block.cal-current { color: var(--primary-hover); }
/* Calendar events and weight under a diary day heading. */
.diary-day-cal { margin: var(--space-3) 0 0; }
.diary-day-cal p { margin: 0; }
/* === Calendars ===
Header maintenance chip + dropdown, and the calendars folder page. */
.maint-toggle { display: inline-flex; align-items: center; }
.maint-count { margin-left: 0.3rem; }
.maint-toggle.is-idle { color: var(--text-muted); }
.maint-menu {
min-width: 20rem;
max-width: calc(100vw - 2rem);
padding: var(--space-2) var(--space-3);
white-space: normal;
}
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
.maint-list {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-2);
row-gap: 0.15rem;
font-size: var(--font-sm);
}
.maint-list > .muted { grid-column: 1 / -1; }
.maint-list + .panel-header { margin-top: var(--space-3); }
.maint-when { color: var(--text-muted); white-space: nowrap; }
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
.cal-scroll { overflow-x: auto; }
.cal-table .nowrap { white-space: nowrap; }
.cal-error { color: var(--danger); font-size: var(--font-sm); }
.cal-sync { margin-top: var(--space-3); }
/* === Fitness dashboard === /* === Fitness dashboard ===
Server-rendered inline SVG charts. Geometry comes precomputed from Go; Server-rendered inline SVG charts. Geometry comes precomputed from Go;
colors and strokes are applied here via classes so the inline SVG follows colors and strokes are applied here via classes so the inline SVG follows
the theme palette. */ the theme palette. */
.fitness-chart { position: relative; padding: var(--space-3); } .fitness-chart { padding: var(--space-3); }
.fitness-chart-header { margin-bottom: var(--space-2); } .fitness-chart-header { margin-bottom: var(--space-2); }
.fitness-controls { gap: var(--space-3); }
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
.fitness-range { width: auto; font-size: var(--font-sm); } .fitness-range { width: auto; font-size: var(--font-sm); }
.fitness-empty { .fitness-empty {
border: var(--border-dashed); border: var(--border-dashed);
@@ -919,319 +828,8 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); } .fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; } .fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
.fitness-svg .chart-dot { fill: var(--link); } .fitness-svg .chart-dot { fill: var(--link); }
.fitness-svg.is-dense .chart-dot { display: none; } .fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; } .fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
.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 === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -1253,9 +851,7 @@ input.canvas-swatch { background: none; }
@media (min-width: 1101px) { @media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;} .actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;} .actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; } .actions-menu .btn-block { width: auto; padding: 0;}
.actions-menu .btn-icon { display: inline-block; }
.actions-menu .btn-label { display: none; }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -1304,3 +900,319 @@ input.canvas-swatch { background: none; }
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; } .list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);} .toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
} }
/* === Canvas ===
A canvas is an alternative view of a page (see canvas.go). The tab strip is
shared with the normal page template; everything below it only exists in
canvas mode. */
/* --- view switcher (rendered on normal pages too) --- */
.canvas-tabs {
gap: 0;
flex-wrap: wrap;
border-bottom: var(--border-dashed);
margin-bottom: var(--space-4);
}
.canvas-tab {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
background: none;
border: 1px solid transparent;
border-bottom: none;
/* Overlap the strip's own border so the active tab joins it. */
margin-bottom: -1px;
font: inherit;
font-size: var(--font-sm);
cursor: pointer;
}
.canvas-tab:hover { color: var(--link-hover); }
.canvas-tab.is-active {
color: var(--text);
border-color: var(--secondary);
background: var(--bg-panel);
}
.canvas-tab-add { color: var(--secondary); }
/* --- full-bleed view mode ---
The canvas owns the viewport instead of the centred reading column: main
loses its max-width and padding, and the centre column stops scrolling so the
surface can handle pan itself. */
body.canvas-mode .center { overflow: hidden; }
body.canvas-mode main {
max-width: none;
padding: 0;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
body.canvas-mode footer { max-width: none; }
body.canvas-mode .canvas-tabs { margin: 0; padding: var(--space-2) var(--space-4) 0; }
.canvas-app {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.canvas-toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
padding: 0.4rem 0.6rem;
background: var(--bg-panel-hover);
border-top: var(--border);
border-bottom: var(--border);
flex: 0 0 auto;
}
.canvas-status { margin-left: auto; padding-left: var(--space-3); }
.canvas-swatch {
display: inline-block;
width: 0.7em;
height: 0.7em;
vertical-align: middle;
margin-right: 0.3em;
border: 1px solid var(--bg);
}
.canvas-swatch[data-swatch="1"] { background: var(--canvas-1); }
.canvas-swatch[data-swatch="2"] { background: var(--canvas-2); }
.canvas-swatch[data-swatch="3"] { background: var(--canvas-3); }
.canvas-swatch[data-swatch="4"] { background: var(--canvas-4); }
.canvas-swatch[data-swatch="5"] { background: var(--canvas-5); }
.canvas-swatch[data-swatch="6"] { background: var(--canvas-6); }
/* --- surface, world, layers ---
touch-action:none hands every touch gesture to the pointer handlers; without
it the browser claims one-finger drags for scrolling and two-finger for page
zoom, and neither reaches the canvas. */
.canvas-surface {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
touch-action: none;
outline: none;
cursor: grab;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1px);
background-repeat: repeat;
}
.canvas-surface.is-grid-hidden { background-image: none; }
/* The world is a zero-size anchor: all of its content is absolutely positioned
in world coordinates and the whole thing is moved by one transform, so pan
and zoom never touch a single node's layout. */
.canvas-world {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas-layer { position: absolute; top: 0; left: 0; }
/* Drawn in world coordinates with no viewport of its own; overflow:visible is
what lets it paint outside its 1x1 box, including at negative coordinates. */
.canvas-edges {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
overflow: visible;
}
/* --- nodes --- */
.canvas-node {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid var(--node-accent, var(--secondary));
background: var(--bg-panel);
/* Visible so the connection ports and resize handles, which sit on the
border, are not clipped; the body clips its own content instead. */
overflow: visible;
contain: layout paint;
}
.canvas-node[data-color="1"] { --node-accent: var(--canvas-1); }
.canvas-node[data-color="2"] { --node-accent: var(--canvas-2); }
.canvas-node[data-color="3"] { --node-accent: var(--canvas-3); }
.canvas-node[data-color="4"] { --node-accent: var(--canvas-4); }
.canvas-node[data-color="5"] { --node-accent: var(--canvas-5); }
.canvas-node[data-color="6"] { --node-accent: var(--canvas-6); }
.canvas-node.is-selected { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
.canvas-node.is-edge-target { outline: 2px dashed var(--link); outline-offset: 1px; }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--space-3);
font-size: var(--font-sm);
}
.canvas-node-body.content { margin-bottom: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body h1 { font-size: 1.1rem; }
.canvas-node-body h2 { font-size: 1rem; }
.canvas-node-body h3 { font-size: 0.95rem; }
/* Task checkboxes are rendered enabled for pages, where page/tasks.js writes
the toggle back to index.md. Inside a canvas node there is no such wiring and
the markdown lives in the .canvas file, so a click would be a lie. */
.canvas-node-body .task-checkbox { pointer-events: none; }
.canvas-node-title {
flex: 0 0 auto;
padding: var(--space-1) var(--space-3);
font-size: var(--font-xs);
color: var(--text-muted);
border-bottom: var(--border-dashed);
}
/* A transcluded image or player fills its node edge to edge. */
.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; }
.canvas-broken { color: var(--danger-hover); }
.canvas-file-fallback { padding: var(--space-2) 0; }
.canvas-link-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
align-items: flex-start;
max-width: 100%;
}
.canvas-link-host { color: var(--link); }
.canvas-link-url { font-size: var(--font-xs); color: var(--text-muted); max-width: 100%; }
.link-favicon { vertical-align: middle; margin-right: 3px; }
.canvas-node-group {
background: rgba(255, 255, 255, 0.03);
border: 1px dashed var(--node-accent, var(--bg-panel-hover));
background-position: center;
}
.canvas-node-group[data-bg-style="cover"] { background-size: cover; }
.canvas-node-group[data-bg-style="ratio"] { background-size: contain; background-repeat: no-repeat; }
.canvas-node-group[data-bg-style="repeat"] { background-repeat: repeat; }
.canvas-node-group .canvas-node-body { display: none; }
.canvas-group-label {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
color: var(--text-muted);
}
/* --- ports and handles ---
Both are positioned on the node border (the negative margin centres them on
it) and stay invisible until the node is hovered or selected, so a dense
canvas is not covered in dots. */
.canvas-port {
position: absolute;
width: 12px;
height: 12px;
margin: -6px;
border-radius: 50%;
background: var(--bg);
border: 1px solid var(--secondary);
opacity: 0;
cursor: crosshair;
}
.canvas-node:hover .canvas-port,
.canvas-node.is-selected .canvas-port { opacity: 1; }
.canvas-port:hover { background: var(--primary-hover); }
.canvas-port-top { top: 0; left: 50%; }
.canvas-port-bottom { top: 100%; left: 50%; }
.canvas-port-left { top: 50%; left: 0; }
.canvas-port-right { top: 50%; left: 100%; }
.canvas-handle {
position: absolute;
width: 10px;
height: 10px;
margin: -5px;
background: var(--primary-hover);
opacity: 0;
}
.canvas-node.is-selected .canvas-handle { opacity: 1; }
.canvas-handle-nw { top: 0; left: 0; cursor: nwse-resize; }
.canvas-handle-ne { top: 0; left: 100%; cursor: nesw-resize; }
.canvas-handle-se { top: 100%; left: 100%; cursor: nwse-resize; }
.canvas-handle-sw { top: 100%; left: 0; cursor: nesw-resize; }
/* --- edges ---
The hit path is a fat transparent stroke under the visible curve: a 2px
bezier is effectively unclickable without it. */
.canvas-edge-hit {
stroke: transparent;
stroke-width: 16;
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-line { stroke-width: 2; pointer-events: none; }
.canvas-edge-head { pointer-events: none; }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); stroke-width: 3; }
.canvas-edge.is-selected .canvas-edge-head { fill: var(--primary-hover); }
.canvas-edge-label {
font-family: "Iosevka Etoile", monospace;
font-size: 12px;
fill: var(--text);
/* A stroke painted under the fill gives the label a halo against whatever
it overlaps, without measuring a background rect. */
stroke: var(--bg);
stroke-width: 4;
paint-order: stroke;
pointer-events: none;
}
.canvas-edge-temp {
stroke: var(--primary-hover);
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
}
/* --- overlays --- */
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: rgba(214, 77, 149, 0.12);
pointer-events: none;
}
.canvas-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 30rem;
padding: var(--space-4);
text-align: center;
pointer-events: none;
}
/* The node editor is fixed to the viewport rather than mounted in the zoomed
world: CodeMirror measures itself in device pixels and a scaled ancestor
breaks its caret and selection maths. */
.canvas-editor {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
}
.canvas-editor .panel-header { padding: var(--space-2) var(--space-3); margin-bottom: 0; }
.canvas-editor-mount { flex: 1 1 auto; min-height: 0; overflow: auto; }
.canvas-editor-mount .cm-editor { height: 100%; }
/* The shared editor theme sets a 60vh floor for the full-page editor; in a
node-sized panel that would force a scrollbar at every size. The theme is
injected at runtime and lands after this sheet, so it needs overriding. */
.canvas-editor-mount .cm-scroller { min-height: 0 !important; }
@media (max-width: 600px) {
.canvas-toolbar { gap: var(--space-2); padding: var(--space-2); }
.canvas-toolbar .btn-tool { padding: 0.2rem 0.4rem; }
body.canvas-mode .canvas-tabs { padding: var(--space-2) var(--space-2) 0; }
/* Touch has no hover, so ports would never appear; show them on selection
only and make both targets finger-sized. */
.canvas-port { width: 16px; height: 16px; margin: -8px; }
.canvas-handle { width: 14px; height: 14px; margin: -7px; }
}
+247 -31
View File
@@ -1,29 +1,43 @@
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
// radio named "pick", folders expand natively and lazy-load via htmx. This
// file only hosts the tree in a modal and reports the checked row.
//
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
// (default true: offer "/" in folder modes), onSelect(path, kind).
(function () { (function () {
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
}
function encodePath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
}
function fetchFolder(path) {
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function openTreePicker(opts) { function openTreePicker(opts) {
opts = opts || {}; opts = opts || {};
var mode = opts.mode || 'folder'; var mode = opts.mode || 'folder';
var initialPath = opts.initialPath || '/';
var allowRoot = opts.allowRoot !== false;
var hideFiles = !!opts.hideFiles;
var preselect = opts.preselect || null;
var container = document.createElement('div'); var container = document.createElement('div');
var treeEl = document.createElement('div'); var treeEl = document.createElement('div');
treeEl.className = 'tree-picker panel'; treeEl.className = 'tree-picker panel';
treeEl.textContent = '…';
var selectedPathEl = document.createElement('div'); var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted'; selectedPathEl.className = 'tree-selected-path muted';
selectedPathEl.textContent = ' '; selectedPathEl.textContent = '\u00a0';
container.appendChild(treeEl); container.appendChild(treeEl);
container.appendChild(selectedPathEl); container.appendChild(selectedPathEl);
function checked() { var selected = null; // { path, kind, rowEl }
return treeEl.querySelector('input[name="pick"]:checked');
}
var handle = openModal({ var handle = openModal({
title: opts.title || 'Pick', title: opts.title || 'Pick',
@@ -32,34 +46,236 @@
label: opts.confirmLabel || 'SELECT', label: opts.confirmLabel || 'SELECT',
initiallyDisabled: true, initiallyDisabled: true,
onConfirm: function () { onConfirm: function () {
var pick = checked(); if (!selected) return;
if (!pick) return;
handle.close(); handle.close();
if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind); if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
} }
} }
}); });
function sync() { function setSelected(path, kind, rowEl) {
var pick = checked(); if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
selectedPathEl.textContent = pick ? pick.value : ' '; selected = { path: path, kind: kind, rowEl: rowEl };
handle.setConfirmDisabled(!pick); rowEl.classList.add('is-selected');
selectedPathEl.textContent = path;
handle.setConfirmDisabled(false);
} }
treeEl.addEventListener('change', sync);
var url = '/?tree&pick=' + mode + function isSelectable(kind) {
(opts.hideFiles ? '' : '&files=1') + if (mode === 'any') return true;
(opts.allowRoot !== false ? '&root=1' : '') + if (mode === 'folder') return kind === 'folder';
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') + if (mode === 'file') return kind === 'file';
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : ''); return false;
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () { }
sync();
var pick = checked(); // buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
if (pick) { // for files and idempotent for folders (resolves with the already-
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {} // 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');
}
row.appendChild(chevron);
var label = document.createElement('span');
label.className = 'tree-name';
label.textContent = name;
row.appendChild(label);
var fullPath = joinPath(parentPath, name);
var childrenEl = null;
var loadPromise = null;
var isOpen = false;
function expand() {
if (kind !== 'folder') return Promise.resolve(null);
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
row.parentNode.insertBefore(childrenEl, row.nextSibling);
chevron.textContent = '\u25be'; // ▾
isOpen = true;
if (!loadPromise) {
loadPromise = loadInto(fullPath, childrenEl);
}
return loadPromise;
}
function collapse() {
if (kind !== 'folder' || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '\u25b8';
isOpen = false;
}
chevron.addEventListener('click', function (e) {
e.stopPropagation();
if (isOpen) collapse(); else expand();
});
row.addEventListener('click', function () {
if (isSelectable(kind)) {
setSelected(fullPath, kind, row);
} else if (kind === 'folder') {
if (isOpen) collapse(); else expand();
} }
}); });
if (kind === 'folder') {
row.addEventListener('dblclick', function (e) {
e.preventDefault();
if (isOpen) collapse(); else expand();
});
}
return {
rowEl: row,
name: name,
kind: kind,
childrenEl: function () { return childrenEl; },
expand: expand
};
}
// loadInto fetches folderPath and populates `target` with rows. Returns
// an array of the row objects on success, or [] on failure.
function loadInto(folderPath, target) {
target.textContent = '';
var loading = document.createElement('div');
loading.className = 'tree-row is-disabled';
loading.textContent = '\u2026';
target.appendChild(loading);
return fetchFolder(folderPath).then(function (resp) {
target.textContent = '';
var rows = [];
(resp.entries || []).forEach(function (e) {
if (hideFiles && e.kind !== 'folder') return;
var r = buildRow(resp.path, e.name, e.kind);
target.appendChild(r.rowEl);
rows.push(r);
});
if (rows.length === 0) {
var empty = document.createElement('div');
empty.className = 'tree-row is-disabled';
empty.textContent = '(empty)';
target.appendChild(empty);
}
return rows;
}).catch(function () {
target.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () {
loadInto(folderPath, target);
});
target.appendChild(err);
return [];
});
}
// Root selection row — visible when allowRoot and mode accepts folders.
if (allowRoot && isSelectable('folder')) {
var rootRow = document.createElement('div');
rootRow.className = 'tree-row';
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
rootRow.addEventListener('click', function () {
setSelected('/', 'folder', rootRow);
});
treeEl.appendChild(rootRow);
}
var rootChildren = document.createElement('div');
treeEl.appendChild(rootChildren);
// Expand the ancestor chain of initialPath so the user lands in
// context. For root, just load root children.
var segs = (initialPath || '/').split('/').filter(Boolean);
var preselectSegs = preselect
? preselect.split('/').filter(Boolean)
: null;
loadInto('/', rootChildren).then(function (rows) {
return expandChain(rows, segs, '/');
}).then(function () {
if (preselectSegs === null) return;
if (preselectSegs.length === 0) {
// Preselect root itself (if allowed).
if (allowRoot && isSelectable('folder')) {
var root = treeEl.querySelector('.tree-row');
if (root) setSelected('/', 'folder', root);
}
return;
}
selectByPath(preselectSegs);
});
// expandChain walks `segments`, looking up each by name in the current
// row list, expanding it, and recursing into its children.
function expandChain(rows, segments, basePath) {
if (segments.length === 0) return Promise.resolve();
var seg = segments[0];
var match = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].kind === 'folder' && rows[i].name === seg) {
match = rows[i];
break;
}
}
if (!match) return Promise.resolve();
return match.expand().then(function (childRows) {
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
});
}
// selectByPath walks the visible tree rows to locate the row matching
// `segments` and marks it selected. Assumes its ancestors are already
// expanded (expandChain ran first).
function selectByPath(segments) {
var container = rootChildren;
var path = '/';
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var kids = container.children;
var found = null;
for (var j = 0; j < kids.length; j++) {
var row = kids[j];
if (!row.classList || !row.classList.contains('tree-row')) continue;
var nm = row.querySelector('.tree-name');
if (nm && nm.textContent === seg) { found = row; break; }
}
if (!found) return;
path = joinPath(path, seg);
if (i === segments.length - 1) {
if (isSelectable('folder')) setSelected(path, 'folder', found);
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
return;
}
var next = found.nextSibling;
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
container = next;
}
}
return handle; return handle;
} }
+215
View File
@@ -0,0 +1,215 @@
;(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
@@ -1,18 +0,0 @@
{{/* 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
@@ -1,677 +0,0 @@
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
@@ -1,283 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,296 +0,0 @@
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
}
+536 -423
View File
File diff suppressed because it is too large Load Diff
-286
View File
@@ -1,286 +0,0 @@
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
@@ -1,86 +0,0 @@
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)
}
}
+1 -26
View File
@@ -335,7 +335,7 @@ type calendarData struct {
Years []calYear Years []calYear
} }
var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html")) var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now() today := time.Now()
@@ -533,8 +533,6 @@ type diarySection struct {
EditURL string // year-file section edit URL ("" = no edit button) EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line) Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto Photos []diaryPhoto
Events string // day's calendar events, comma-separated (level 3 only)
Weight string // day's weight from the weight calendar (level 3 only)
} }
type diaryContentData struct { type diaryContentData struct {
@@ -625,7 +623,6 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
out := buildFileSections(sections, photos, yearURL) out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL) out = appendVirtualEntries(out, sections, photos, year, yearURL)
attachCalendarLines(out, year)
return renderDiaryContent(out) return renderDiaryContent(out)
} }
@@ -749,28 +746,6 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
return out return out
} }
// attachCalendarLines fills the events and weight lines of every day section
// (real or virtual) from the calendars' diary and weight roles.
func attachCalendarLines(sections []diarySection, year int) {
lines := calendarDayLines(
time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local),
time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.Local))
if len(lines) == 0 {
return
}
for i := range sections {
s := &sections[i]
if s.Level != 3 {
continue
}
if _, _, _, ok := parseISODate(s.ID); !ok {
continue
}
l := lines[s.ID[:10]]
s.Events, s.Weight = l.Events, l.Weight
}
}
// isRealDateSection reports whether a rendered diarySection is one of the // isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry // date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against. // interleave sorts against.
-4
View File
@@ -1,4 +0,0 @@
node_modules/
.tmp/
test-results/
playwright-report/
-58
View File
@@ -1,58 +0,0 @@
{
"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
@@ -1,11 +0,0 @@
{
"name": "datascape-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1"
}
}
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,27 +0,0 @@
// 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
@@ -1,155 +0,0 @@
// 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
@@ -1,151 +0,0 @@
// 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
@@ -1,392 +0,0 @@
// End-to-end coverage of the page UI. Tests drive the app through visible
// labels, not implementation details, so they survive changes to the
// client-side mechanics.
//
// Every test builds the pages it needs under its own fresh top-level folder
// (sandbox), so tests don't depend on each other's leftovers and can run alone
// or in any order. Names are matched exactly: other tests' folders share the
// sidebar and the search index.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
// Fail any test that logs a JS error.
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
throw err;
});
});
// randomWord returns n random lowercase letters.
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh top-level folder holding pages, keyed by path
// relative to it ("" is the folder's own page); a null body makes an empty
// folder. Returns the folder name.
function sandbox(pages) {
const root = "t" + randomWord(7);
for (const [rel, body] of Object.entries(pages)) {
const dir = join(wiki, root, rel);
mkdirSync(dir, { recursive: true });
if (body !== null) writeFileSync(join(dir, "index.md"), body);
}
return root;
}
const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8");
const exactly = (name) => new RegExp(`^${name}$`);
const sidebar = (page) => page.locator("aside.tree-sidebar");
const modal = (page) => page.locator(".modal");
const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) });
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
// wide ones; address them by title and open the dropdown only when present.
async function pageAction(page, title) {
const toggle = page.getByRole("button", { name: "ACTIONS" });
if (await toggle.isVisible()) await toggle.click();
await page.locator("header").getByTitle(title).click();
}
// movePage runs the move flow from the current page: keep the preselected
// parent, then enter name.
async function movePage(page, name) {
await pageAction(page, "Move page (M)");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(name);
await modal(page).getByRole("button", { name: "MOVE" }).click();
}
test("renders a page and navigates via the sidebar", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto("/");
await expect(page.locator("main h1")).toContainText("Home");
await link(sidebar(page), root).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
// The sidebar expands the active folder's children.
await expect(link(sidebar(page), "Child")).toBeVisible();
});
test("task checkbox toggle persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await expect(box).not.toBeChecked();
await box.check();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [x] first task");
await page.reload();
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
});
test("unchecking a task persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").nth(1);
await expect(box).toBeChecked();
await box.uncheck();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [ ] second task");
});
test("a failed task save reverts the checkbox", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" });
page.on("dialog", (d) => d.dismiss());
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await box.click();
await expect(box).toBeEnabled();
await expect(box).toBeChecked();
expect(readPage(root)).toContain("- [x] done task");
});
test("view settings switch the listing to thumbnails", async ({ page }) => {
const root = sandbox({ "": "# Listing\n", Child: "# Child\n" });
await page.goto(`/${root}/`);
await expect(page.locator("main table.data-table")).toBeVisible();
await page.getByTitle("View & sorting").click();
await modal(page).getByText("Thumbnail").click();
await modal(page).getByLabel("Sort by").selectOption("size");
await modal(page).getByText("Desc").click();
await modal(page).getByRole("button", { name: "SAVE" }).click();
await expect(page.locator("main .thumb-grid")).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
// Reopening shows the saved state.
await page.getByTitle("View & sorting").click();
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
await expect(modal(page).getByLabel("Desc")).toBeChecked();
});
test("editor saves a page", async ({ page }) => {
const root = sandbox({ "": "# Editable\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Edit page (E)");
await expect(page).toHaveURL(/\?edit/);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type("\nAdded by e2e.\n");
await page.getByRole("button", { name: "SAVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main")).toContainText("Added by e2e.");
expect(readPage(root)).toContain("Added by e2e.");
// Save replaces the editor's history entry: Back leaves the page entirely.
await page.goBack();
await expect(page).not.toHaveURL(/\?edit/);
});
test("move renames a page", async ({ page }) => {
const root = sandbox({ Beta: "# Beta\n" });
await page.goto(`/${root}/Beta/`);
await movePage(page, "Gamma");
await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`));
await expect(page.locator("main h1")).toContainText("Beta");
});
test("move onto an empty folder offers a merge", async ({ page }) => {
const root = sandbox({ Alpha: "# Alpha\n", Empty: null });
await page.goto(`/${root}/Alpha/`);
await movePage(page, "Empty");
await expect(modal(page)).toContainText("Merge folders?");
await modal(page).getByRole("button", { name: "MERGE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`));
await expect(page.locator("main h1")).toContainText("Alpha");
});
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" });
await page.goto(`/${root}/Mover/`);
// Dismiss the alert from the handler: an open alert blocks the click.
const messages = [];
page.on("dialog", (d) => {
messages.push(d.message());
d.dismiss();
});
await movePage(page, "Taken");
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
await expect(modal(page)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`));
});
test("delete removes a page and returns to the parent", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" });
await page.goto(`/${root}/Notes/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toContainText(`Delete /${root}/Notes/`);
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(link(sidebar(page), "Notes")).toHaveCount(0);
});
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/`);
await page.goto(`/${root}/Gone/`);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("a server dialog works from the first moment it is shown", async ({ page }) => {
// htmx wires up swapped-in forms only when it settles. Submit in the very
// next task after the dialog is inserted: if the form isn't wired yet, the
// browser submits it natively (a GET) and the page is not deleted.
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await page.evaluate(() => {
document.addEventListener("htmx:afterSwap", (e) => {
if (e.detail.target.id !== "modal-host") return;
const submit = e.detail.target.querySelector('button[type="submit"]');
setTimeout(() => submit.click(), 0);
}, { once: true });
});
await pageAction(page, "Delete page");
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
const root = sandbox({ "": "# Keep me\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toBeVisible();
// CANCEL is focused, so Enter must not delete.
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(modal(page)).toHaveCount(0);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "CANCEL" }).click();
await expect(modal(page)).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
expect(readPage(root)).toContain("Keep me");
});
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
const root = sandbox({ "": "# Here\n" });
const treeRequests = [];
page.on("request", (r) => {
if (r.url().includes("?tree")) treeRequests.push(r.url());
});
const res = await page.request.get(`/${root}/`);
expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`);
await page.goto(`/${root}/`);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root);
expect(treeRequests).toEqual([]);
});
test("sidebar folders expand lazily and collapse", async ({ page }) => {
const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" });
await page.goto("/");
const chevron = treeRow(sidebar(page), root).locator(".tree-chevron");
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
await expect(link(sidebar(page), "One")).toHaveCount(0);
expect(await glyph()).toBe('"▸"');
await chevron.click();
await expect(link(sidebar(page), "One")).toBeVisible();
expect(await glyph()).toBe('"▾"');
// One has a subfolder, Two has none: only One gets a chevron.
await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1);
await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/);
await chevron.click();
await expect(link(sidebar(page), "One")).toBeHidden();
});
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" });
await page.goto(`/${root}/Mover/`);
await pageAction(page, "Move page (M)");
// The picker opens with the current parent expanded; Tree is still closed.
const picker = modal(page).locator(".tree-picker");
await treeRow(picker, "Tree").locator(".tree-chevron").click();
await treeRow(picker, "One").locator(".tree-chevron").click();
await picker.getByText("Deep", { exact: true }).click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`));
});
test("tree picker can pick the root", async ({ page }) => {
const root = sandbox({ Two: "# Two\n" });
await page.goto(`/${root}/Two/`);
await pageAction(page, "Move page (M)");
// The current parent is preselected and its chain expanded.
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`);
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(`${root}-top`);
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`));
});
test("editor embed picker selects a file", async ({ page }) => {
const root = sandbox({ "": "# Embeds\n" });
writeFileSync(join(wiki, root, "photo.jpg"), "x");
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+G");
const picker = modal(page).locator(".tree-picker");
// Folder rows aren't selectable in file mode; the file is.
await picker.getByText("photo.jpg").click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/photo.jpg`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
});
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
const root = sandbox({ "": "# Mobile\n" });
await page.setViewportSize({ width: 400, height: 800 });
await page.goto(`/${root}/`);
await expect(sidebar(page)).toBeHidden();
await page.getByRole("button", { name: "ACTIONS" }).click();
await page.getByTitle("Folders").click();
const overlay = page.locator(".overlay");
await expect(link(overlay, root)).toBeVisible();
await link(overlay, root).click();
await expect(overlay).toHaveCount(0);
});
// seedSearch creates pages whose names share a random word (so no other
// test's pages match it) and reindexes. Returns the sandbox, the word and its
// capitalized form used in the names.
async function seedSearch(page) {
const word = randomWord(8);
const Word = word[0].toUpperCase() + word.slice(1);
const pages = { "": "# Search\n" };
for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) {
pages[`${Word} ${n}`] = `# ${Word} ${n}\n`;
}
const root = sandbox(pages);
expect((await page.request.post("/_reindex")).status()).toBe(204);
return { root, word, Word };
}
const suggest = (page) => page.locator("header .suggest-dropdown");
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(`${word} finch`);
const first = suggest(page).locator(".suggest-row").first();
await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`);
await expect(first.locator("strong")).toHaveText([Word, "Finch"]);
await input.press("ArrowDown");
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`));
});
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(word[0]);
await page.waitForTimeout(300);
await expect(suggest(page)).toBeHidden();
await input.fill(word);
const { total } = await (await page.request.get(`/_search?q=${word}`)).json();
expect(total).toBeGreaterThan(5);
await expect(suggest(page).locator(".suggest-footer")).toHaveText(`Show all ${total} matches`);
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
await input.press("Escape");
await expect(suggest(page)).toBeHidden();
await input.fill(`${word} crossing`);
await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`));
await page.goto("/");
await page.locator("header .search-input").fill(word);
await suggest(page).locator(".suggest-footer").click();
await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`));
});
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+P");
const query = modal(page).getByPlaceholder("Search query");
await query.fill(`${word} crossing`);
await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click();
await expect(query).toHaveValue(`${Word} Zebra Crossing`);
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`);
});
test("editor [[ autocomplete lists matching pages", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type(`\n[[${word} finc`);
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`);
});
+1 -1
View File
@@ -92,7 +92,7 @@ func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, e
w.WriteString(strconv.Itoa(embedThumbW)) w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`) w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name))) w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`" loading="lazy"></a>`) w.WriteString(`"></a>`)
if captioned { if captioned {
w.WriteString(`<figcaption>`) w.WriteString(`<figcaption>`)
} }
+164 -223
View File
@@ -2,12 +2,14 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"math" "math"
"net/http" "net/http"
"net/url" "os"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -18,6 +20,10 @@ func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{}) pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
} }
// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"
type fitnessHandler struct{} type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders // redirect: the fitness dashboard has no virtual URLs; everything renders
@@ -28,48 +34,20 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request)
// handle renders the dashboard for folders whose .page-settings declares // handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so // type = fitness. Markdown content and the folder listing stay visible so
// the folder can still hold notes. // the user can verify an uploaded export arrived.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage { func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath) s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" { if s == nil || s.Type != "fitness" {
return nil return nil
} }
weightView, weeklyView := fitnessViews(r.URL.Query()) weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{ return &specialPage{
Content: renderFitnessDashboard(fsPath, weightView, weeklyView), Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
SuppressTOC: true, 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 === // === Time ranges ===
type fitnessRange struct { type fitnessRange struct {
@@ -94,50 +72,6 @@ func validFitnessRange(v, fallback string) string {
return fallback return fallback
} }
// fitnessView is one chart's view state from the query string: ?<param>=<range>
// and ?<param>-absolute=1 to start the y axis at 0.
type fitnessView struct {
Range string
Absolute bool
}
func chartViewFrom(q url.Values, param, fallback string) fitnessView {
return fitnessView{
Range: validFitnessRange(q.Get(param), fallback),
Absolute: q.Get(param+"-absolute") == "1",
}
}
// 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 { func rangeMonths(v string) int {
for _, r := range fitnessRanges { for _, r := range fitnessRanges {
if r.Value == v { if r.Value == v {
@@ -147,6 +81,95 @@ func rangeMonths(v string) int {
return 0 return 0
} }
// === Waistline export parsing ===
// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
val float64
ok bool
}
func (n *wlNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
n.val = v
n.ok = true
return nil
}
// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
if len(s) < 10 {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return time.Time{}, false
}
return t, true
}
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
var s struct {
Goals map[string]json.RawMessage `json:"goals"`
}
if json.Unmarshal(settings, &s) != nil {
return 0, false
}
var g struct {
GoalList []struct {
Goal []wlNum `json:"goal"`
} `json:"goal-list"`
}
if json.Unmarshal(s.Goals[key], &g) != nil {
return 0, false
}
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
return 0, false
}
n := g.GoalList[0].Goal[0]
return n.val, n.ok
}
func exportWeightUnit(settings json.RawMessage) string {
var s struct {
Units struct {
Weight string `json:"weight"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
return s.Units.Weight
}
return "kg"
}
// === Series extraction === // === Series extraction ===
type weightPoint struct { type weightPoint struct {
@@ -155,34 +178,26 @@ type weightPoint struct {
days int // >0: weekly mean over this many measured days days int // >0: weekly mean over this many measured days
} }
// fitnessWeights reads the whole weight calendar up to today: one point per // extractWeights computes the weight series from the export: one point per
// day, sorted by date. Dates are UTC midnights to match the chart's today. // diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
func fitnessWeights(today time.Time) []weightPoint { // one wins.
byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1)) func extractWeights(ex *wlExport) []weightPoint {
weights := make([]weightPoint, 0, len(byDay)) byDate := map[time.Time]float64{}
for key, w := range byDay { for _, e := range ex.Diary {
d, err := time.Parse("2006-01-02", key) day, ok := exportDate(e.DateTime)
v, err2 := strconv.ParseFloat(w, 64) if !ok || !e.Stats.Weight.ok {
if err != nil || err2 != nil {
continue continue
} }
byDate[day] = e.Stats.Weight.val
}
weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate {
weights = append(weights, weightPoint{date: d, value: v}) weights = append(weights, weightPoint{date: d, value: v})
} }
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights return weights
} }
// weightGoal reads the optional weight-goal key from the fitness folder's
// .page-settings.
func weightGoal(fsPath string) (float64, bool) {
v, ok := readSettingsFile(fsPath)["weight-goal"]
if !ok {
return 0, false
}
g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64)
return g, err == nil
}
func mondayOf(t time.Time) time.Time { func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
} }
@@ -265,9 +280,8 @@ type fitnessTickVM struct {
Pos, Label, Anchor string Pos, Label, Anchor string
} }
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
type fitnessDotVM struct { type fitnessDotVM struct {
X, Y, Date, Value string X, Y, Title string
} }
type fitnessGoalVM struct { type fitnessGoalVM struct {
@@ -278,7 +292,6 @@ type fitnessChartVM struct {
Title string Title string
Param string Param string
Options []fitnessOptVM Options []fitnessOptVM
Absolute bool
Empty bool Empty bool
ViewW, ViewH string ViewW, ViewH string
@@ -288,7 +301,6 @@ type fitnessChartVM struct {
Goal *fitnessGoalVM Goal *fitnessGoalVM
Lines []string // polyline points attributes Lines []string // polyline points attributes
Dots []fitnessDotVM Dots []fitnessDotVM
Dense bool // too many points to draw markers
} }
type fitnessDashVM struct { type fitnessDashVM struct {
@@ -296,13 +308,13 @@ type fitnessDashVM struct {
Charts []fitnessChartVM Charts []fitnessChartVM
} }
func newChartVM(title, param string, view fitnessView) fitnessChartVM { func newChartVM(title, param, sel string) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges)) opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges { for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range} opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
} }
return fitnessChartVM{ return fitnessChartVM{
Title: title, Param: param, Options: opts, Absolute: view.Absolute, Title: title, Param: param, Options: opts,
ViewW: svgNum(chartW), ViewH: svgNum(chartH), ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop), PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom), PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
@@ -311,7 +323,7 @@ func newChartVM(title, param string, view fitnessView) fitnessChartVM {
} }
// yTickVMs places gridlines at fixed multiples of step across the y domain // yTickVMs places gridlines at fixed multiples of step across the y domain
// (5 kg guides on weight charts, 20 kg when the axis starts at 0). // (always 5 kg guides on weight charts, regardless of range).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM { func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step { for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
@@ -334,12 +346,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
// buildWeightChart renders a weight line chart. It serves both the per-day // buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is // series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement. // drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM { func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, view) vm := newChartVM(title, param, sel)
points := all points := all
var x0, x1 time.Time var x0, x1 time.Time
if m := rangeMonths(view.Range); m > 0 { if m := rangeMonths(sel); m > 0 {
x0 = today.AddDate(0, -m, 0) x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0) points = filterWeights(all, x0)
if len(points) == 0 { if len(points) == 0 {
@@ -379,31 +391,21 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnes
pad = 1 pad = 1
} }
sc := chartScale{x0, x1, lo - pad, hi + pad} sc := chartScale{x0, x1, lo - pad, hi + pad}
yStep := 5.0
if view.Absolute {
sc.y0 = 0
sc.y1 = hi + hi*0.05
yStep = 20
}
vm.YTicks = yTickVMs(sc, yStep) vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc) vm.XTicks = timeXTicks(sc, 4)
// One continuous polyline through every point in range — days without a // One continuous polyline through every point in range — days without a
// measurement do not break the line. Every point also gets a marker that // measurement do not break the line. Point markers carry the hover
// feeds the hover tooltip; on dense ranges the markers are hidden and the // <title>; on dense ranges they are dropped and the bare line stays
// bare line stays legible. A single point in range renders as a dot. // legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM { dot := func(p weightPoint) fitnessDotVM {
d := fitnessDotVM{ label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
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,
}
if p.days > 0 { if p.days > 0 {
d.Date = "Week of " + p.date.Format("2006-01-02") label = fmt.Sprintf("Week of %s: %s %s (%d days)",
d.Value += fmt.Sprintf(" · %d days", p.days) p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
} }
return d return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
} }
if len(points) >= 2 { if len(points) >= 2 {
var b strings.Builder var b strings.Builder
@@ -417,10 +419,11 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnes
} }
vm.Lines = append(vm.Lines, b.String()) vm.Lines = append(vm.Lines, b.String())
} }
if len(points) <= 100 {
for _, p := range points { for _, p := range points {
vm.Dots = append(vm.Dots, dot(p)) vm.Dots = append(vm.Dots, dot(p))
} }
vm.Dense = len(points) > 100 }
if hasGoal { if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal)) vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -438,101 +441,34 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
return out return out
} }
// xTickStep is a calendar-aligned spacing for date labels. // timeXTicks places n+1 evenly spaced date labels across a continuous time
type xTickStep struct { // axis; the last label is end-anchored so it stays inside the viewBox.
days, months int func timeXTicks(sc chartScale, n int) []fitnessTickVM {
label func(t time.Time, first bool) string span := sc.x1.Sub(sc.x0)
} var out []fitnessTickVM
prev := ""
var xTickSteps = []xTickStep{ for i := 0; i <= n; i++ {
{days: 1, label: dayLabel}, t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
{days: 7, label: dayLabel}, label := t.Format("2006-01-02")
{days: 14, label: dayLabel}, if label == prev {
{months: 1, label: monthLabel}, continue
{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") prev = label
}
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
// maxXTicks bounds the label count so labels never collide on the axis.
const maxXTicks = 5
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
// month starts or new years, whichever is the finest step that stays within
// maxXTicks. Labels near the plot edges are anchored inward so they stay
// inside the viewBox.
func timeXTicks(sc chartScale) []fitnessTickVM {
var ticks []time.Time
var step xTickStep
for _, step = range xTickSteps {
ticks = alignedTicks(sc.x0, sc.x1, step)
if len(ticks) <= maxXTicks {
break
}
}
out := make([]fitnessTickVM, 0, len(ticks))
for i, t := range ticks {
x := sc.x(t)
anchor := "middle" anchor := "middle"
switch { if i == n {
case x-chartLeft < 24:
anchor = "start"
case chartW-chartRight-x < 24:
anchor = "end" anchor = "end"
} }
out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor}) out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
}
return out
}
// alignedTicks lists every boundary of step within [from, to]. Week steps
// start on Mondays; month and year steps on multiples of the step (Jan/Apr/
// Jul/Oct for quarters, even years for two-year steps).
func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
var t time.Time
if step.days > 0 {
t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
if step.days > 1 {
t = mondayOf(t)
}
} else {
months := from.Year()*12 + int(from.Month()) - 1
months -= months % step.months
t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC)
}
var out []time.Time
for ; !t.After(to); t = t.AddDate(0, step.months, step.days) {
if !t.Before(from) {
out = append(out, t)
}
if len(out) > maxXTicks {
break
}
} }
return out return out
} }
// === Rendering === // === Rendering ===
var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html")) var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML { func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now()) data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
var buf bytes.Buffer var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil { if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err) log.Printf("fitness template: %v", err)
@@ -541,23 +477,28 @@ func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) t
return template.HTML(buf.String()) return template.HTML(buf.String())
} }
// buildFitnessDash reads the weight calendar per request (the calendar store // buildFitnessDash reads and parses the export per request — no caching, no
// caches parsed files). A missing calendar setup becomes an inline notice; // indexes. A read or parse failure (including a truncated mid-upload file)
// the page itself always renders. // becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM { func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 { raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."} 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()}
} }
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := fitnessWeights(today) weights := extractWeights(&ex)
wGoal, hasWGoal := weightGoal(fsPath) wGoal, hasWGoal := goalValue(ex.Settings, "weight")
const unit = "kg" unit := exportWeightUnit(ex.Settings)
return fitnessDashVM{Charts: []fitnessChartVM{ return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightView, buildWeightChart(weights, wGoal, hasWGoal, weightSel,
"weight", "Weight ("+unit+")", unit, today), "weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView, buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
"weekly", "Weekly average weight ("+unit+")", unit, today), "weekly", "Weekly average weight ("+unit+")", unit, today),
}} }}
} }
-130
View File
@@ -1,130 +0,0 @@
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)
}
}
+1 -5
View File
@@ -2,8 +2,4 @@ module datascape
go 1.26.1 go 1.26.1
require ( require github.com/yuin/goldmark v1.8.2
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,6 +1,2 @@
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+90 -132
View File
@@ -2,9 +2,12 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"flag" "flag"
"html/template" "html/template"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@@ -14,26 +17,38 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
) )
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// tmplFuncs is shared by every layout-based template: asset links versioned // editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
// static files (see static.go); the layout header renders the maintenance // appended as ?v=… to its <script> src. The bundle is served immutable under a
// widget. // 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.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"asset": assetURL, "editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon, "fileIcon": fileIcon,
"icon": readIcon,
"maintenanceWidget": maintenanceWidget,
} }
var ( var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html")) 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/toolbar.html", "assets/editor/main.html")) editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html")) searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
) )
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
@@ -64,14 +79,6 @@ type pageTypeHandler interface {
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool) 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(). // pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler var pageTypeHandlers []pageTypeHandler
@@ -82,18 +89,8 @@ func main() {
user := flag.String("user", "", "basic auth username (empty = no auth)") user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password") pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)") reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)")
tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)")
flag.Parse() flag.Parse()
if *tz != "" {
loc, err := time.LoadLocation(*tz)
if err != nil {
log.Fatalf("-tz: %v", err)
}
time.Local = loc
}
root, err := filepath.Abs(*wikiDir) root, err := filepath.Abs(*wikiDir)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -112,25 +109,24 @@ func main() {
initMarkdown() initMarkdown()
if *calendars != "" {
calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars))
rel, err := filepath.Rel(root, calendarsDir)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
log.Fatalf("-calendars must be a folder inside -dir")
}
calendarsURL = "/" + filepath.ToSlash(rel) + "/"
}
authKey, err := loadOrCreateAuthKey(root) authKey, err := loadOrCreateAuthKey(root)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey} h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
http.Handle("/_/", serveStatic()) 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.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
http.HandleFunc("/quickadd", h.handleQuickAdd) http.HandleFunc("/quickadd", h.handleQuickAdd)
http.Handle("/", h) http.Handle("/", h)
@@ -164,10 +160,6 @@ func main() {
}(*reindexInterval) }(*reindexInterval)
} }
if calendarsDir != "" {
startCalendarSync()
}
log.Printf("datascape listening on %s, wiki at %s", *addr, root) log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil)) log.Fatal(http.ListenAndServe(*addr, nil))
} }
@@ -183,21 +175,14 @@ type reqStartKeyT struct{}
var reqStartKey = reqStartKeyT{} var reqStartKey = reqStartKeyT{}
// requestStart returns when the request entered ServeHTTP, or now if unknown. // elapsedMS returns the milliseconds since the request entered ServeHTTP.
func requestStart(r *http.Request) time.Time { func elapsedMS(r *http.Request) int64 {
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok { if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
return start return time.Since(start).Milliseconds()
} }
return time.Now() return 0
} }
// renderTimer is embedded in template data. The footer calls RenderMS while
// the template executes, so the time includes the page's own rendering (and
// the header's maintenance widget), not just the work before it.
type renderTimer struct{ start time.Time }
func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() }
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now())) r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
@@ -205,14 +190,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Dotfiles are never served: .page-settings can hold the CalDAV
// password and .auth-key signs sessions. Nothing links to them (listing,
// tree and search hide them already).
if hasDotSegment(r.URL.Path) {
http.NotFound(w, r)
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") { if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r) h.handleThumb(w, r)
return return
@@ -238,15 +215,19 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
// hit on the file collapses onto that canonical form — the same shape as
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
if target, ok := canvasViewURL(urlPath); ok {
http.Redirect(w, r, target, http.StatusFound)
return
}
}
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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 // Non-existent path: redirect GETs to the canonical slash form so
// the browser URL is consistent, then serve an empty folder page. // the browser URL is consistent, then serve an empty folder page.
// POSTs must not be redirected — the form action has no trailing // POSTs must not be redirected — the form action has no trailing
@@ -273,8 +254,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
urlPath += "/" urlPath += "/"
} }
h.serveDir(w, r, urlPath, fsPath) h.serveDir(w, r, urlPath, fsPath)
} else if isCanvasFile(fsPath) {
h.serveCanvas(w, r, urlPath, fsPath)
} else { } else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so // ?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> // their <img src> can stay relative to the page; /_thumb/<abs path>
@@ -288,17 +267,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
// hasDotSegment reports whether any segment of the cleaned URL path starts
// with a dot.
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(path.Clean("/"+p), "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// slashedURL returns u's path with a trailing slash appended and the query // slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve // string intact — the canonical page form that relative destinations resolve
// against. // against.
@@ -317,18 +285,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
h.handlePost(w, r, urlPath, fsPath) h.handlePost(w, r, urlPath, fsPath)
return return
} }
if r.URL.Query().Has("dialog") {
h.serveDialog(w, r, urlPath, fsPath)
return
}
if isHX(r) && !editMode { // An explicit ?canvas= selects this page's canvas view. Checked ahead of
for _, ph := range pageTypeHandlers { // the pageTypeHandler redirect loop so an explicit view request always wins
if fh, ok := ph.(fragmentHandler); ok && fh.serveFragment(w, r, h.root, fsPath, urlPath) { // over a folder-type redirect (e.g. a canvas kept in a diary year folder).
//
// This is deliberately not a pageTypeHandler: that interface injects content
// into the standard page body, while a canvas replaces the whole body with a
// full-bleed surface and its own controls — a view mode like ?edit, which is
// likewise a template branch here rather than a handler.
if name := r.URL.Query().Get("canvas"); name != "" {
h.serveCanvas(w, r, urlPath, fsPath, name)
return return
} }
}
}
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
@@ -372,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
rendered = renderMarkdown(rawMD) rendered = renderMarkdown(rawMD)
} }
// Canvases in this folder surface as a view switcher above the content, so
// a page and its diagrams read as one thing. Suppressed in the editor, which
// is about the markdown only.
var canvases []canvasRef
if !editMode {
canvases = listCanvases(fsPath, urlPath, "")
}
view, sortKey, order := readPageSettings(fsPath).viewSettings() view, sortKey, order := readPageSettings(fsPath).viewSettings()
var entries []entry var entries []entry
if !editMode && (special == nil || !special.SuppressListing) { if !editMode && (special == nil || !special.SuppressListing) {
@@ -430,12 +407,12 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
Content: rendered, Content: rendered,
Entries: entries, Entries: entries,
View: view, View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC, SuppressTOC: suppressTOC,
} Canvases: canvases,
if !editMode {
data.Tree = sidebarTree(h.root, urlPath)
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -443,31 +420,26 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
if editMode { if editMode {
t = editTmpl t = editTmpl
} }
data.renderTimer = renderTimer{requestStart(r)} data.RenderMS = elapsedMS(r)
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil { if err := t.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
} }
} }
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
query := r.URL.Query() query := r.URL.Query()
// Checked first so ?canvas=X&delete removes that canvas rather than falling
// through to the page-level delete below.
if name := query.Get("canvas"); name != "" {
h.handleCanvasPost(w, r, urlPath, fsPath, name)
return
}
if query.Has("delete") { if query.Has("delete") {
h.handleDelete(w, r, urlPath, fsPath) h.handleDelete(w, r, urlPath, fsPath)
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
// The destination comes either whole (?move=/dst, or the merge h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
// dialog's move field) or as the move dialog's parent + name.
// Body fields take precedence over the bare ?move query key.
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
dst := r.FormValue("move")
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
}
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
@@ -482,14 +454,6 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath) h.handleSettings(w, r, urlPath, fsPath)
return return
} }
if query.Has("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 { if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@@ -601,28 +565,12 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
// Returns nil if the file does not exist. // Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments. // Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings { func readPageSettings(dir string) *pageSettings {
kv := readSettingsFile(dir)
if kv == nil {
return nil
}
// Unknown or missing values fall back to the defaults.
return &pageSettings{
Type: kv["type"],
View: validateView(kv["view"]),
Sort: validateSort(kv["sort"]),
Order: validateOrder(kv["order"]),
}
}
// readSettingsFile returns every key of dir's .page-settings (last one wins),
// or nil if the file does not exist. Other features (e.g. the calendars
// folder) keep their own keys in the same file.
func readSettingsFile(dir string) map[string]string {
data, err := os.ReadFile(filepath.Join(dir, ".page-settings")) data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil { if err != nil {
return nil return nil
} }
kv := map[string]string{} // Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -632,7 +580,17 @@ func readSettingsFile(dir string) map[string]string {
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) 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)
} }
return kv }
return s
} }
-144
View File
@@ -1,144 +0,0 @@
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
}
+7 -9
View File
@@ -14,7 +14,8 @@ import (
// The one exception is a merge: when the source carries a page (index.md) and // The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty // the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge // container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is asked to confirm it. // 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, merge bool) { func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
@@ -50,12 +51,9 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return return
} }
if !merge { if !merge {
// Ask the user to confirm: htmx clients get the merge dialog as // Ask the client to confirm the merge. The header lets it tell
// the next step; others a conflict they can retry with &merge=1. // this apart from an ordinary conflict.
if isHX(r) { w.Header().Set("X-Merge-Available", "1")
renderDialog(w, "merge", struct{ Dest string }{newPath})
return
}
http.Error(w, "destination already exists — merge folders?", http.StatusConflict) http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return return
} }
@@ -96,7 +94,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} }
redirectAfter(w, r, pageURL(newPath)) http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
} }
// canMergeMove reports whether moving onto an existing destination should // canMergeMove reports whether moving onto an existing destination should
@@ -171,7 +169,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
return return
} }
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/")) folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
redirectAfter(w, r, pageURL(parentURL(urlPath))) http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
} }
// normalizeMovePath returns the absolute path with any trailing slash removed, // normalizeMovePath returns the absolute path with any trailing slash removed,
+1 -1
View File
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
redirectAfter(w, r, pageURL(urlPath)) http.Redirect(w, r, urlPath, http.StatusSeeOther)
} }
// writePageSettings performs a read-modify-write of <dir>/.page-settings, // writePageSettings performs a read-modify-write of <dir>/.page-settings,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
) )
var quickAddTmpl = template.Must(template.New("quickadd.html").Funcs(assetFuncs).ParseFS(assets, "assets/quickadd.html")) var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html"))
type quickAddData struct { type quickAddData struct {
To, URL, Title string To, URL, Title string
+46 -53
View File
@@ -7,17 +7,14 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
) )
var md goldmark.Markdown var md goldmark.Markdown
@@ -61,11 +58,45 @@ type pageData struct {
Content template.HTML Content template.HTML
Entries []entry Entries []entry
View string // listing view style: "list" or "thumbnail" View string // listing view style: "list" or "thumbnail"
Sort string // listing sort key: "name" / "modified" / "size"
Order string // listing sort order: "asc" / "desc"
SpecialContent template.HTML SpecialContent template.HTML
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool SuppressTOC bool
Tree template.HTML // sidebar folder tree; empty in edit mode RenderMS int64
renderTimer
// Canvases lists every .canvas file in this folder as a view-switcher tab.
// ActiveCanvas names the one currently being viewed ("" on a normal page),
// and is what makes this page render as a canvas rather than as markdown.
Canvases []canvasRef
ActiveCanvas string
// CanvasJSON is the whole canvas document plus its server-rendered node
// HTML, inlined into the page so opening a canvas costs one request.
CanvasJSON template.JS
// CanvasError carries a parse/validation failure to the template, which
// shows it in place of the canvas instead of failing the request.
CanvasError string
}
// layoutData is the contract every view built on assets/layout.html satisfies.
// The layout is shared by pageData and searchPageData, and a method only one of
// them has fails at render time with a half-written response rather than at
// build time — so each is asserted against the interface here.
type layoutData interface {
ShowPageChrome() bool
}
var (
_ layoutData = pageData{}
_ layoutData = searchPageData{}
)
// ShowPageChrome reports whether the standard page furniture — search box,
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
// render. The editor and the canvas view both take the full width and supply
// their own controls, so both opt out.
func (d pageData) ShowPageChrome() bool {
return !d.EditMode && d.ActiveCanvas == ""
} }
// Allowed values for the listing view settings. Unknown values in the file or // Allowed values for the listing view settings. Unknown values in the file or
@@ -137,56 +168,15 @@ var (
// renderMarkdown converts raw markdown to trusted HTML. // renderMarkdown converts raw markdown to trusted HTML.
func renderMarkdown(raw []byte) template.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 var buf bytes.Buffer
doc := md.Parser().Parse(text.NewReader(raw)) if err := md.Convert(raw, &buf); err != nil {
if base != "" { return ""
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 // Goldmark emits a bare `<table>`; tag it so it picks up the shared
// .data-table styling with the grid modifier (per-cell borders + header). // .data-table styling with the grid modifier (per-cell borders + header).
return bytes.ReplaceAll(buf.Bytes(), []byte("<table>"), []byte(`<table class="data-table data-table-grid">`)) out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
} return template.HTML(out)
// 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, // extractFirstHeading returns the text of the first ATX heading in raw markdown,
@@ -255,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
if name == "index.md" { if name == "index.md" {
continue // rendered above, don't list it continue // rendered above, don't list it
} }
if isCanvasFile(name) {
continue // surfaced as a view-switcher tab above the content
}
f := entry{ f := entry{
Icon: fileIcon(name), Icon: fileIcon(name),
Name: name, Name: name,
@@ -344,10 +337,10 @@ func readIcon(name string) template.HTML {
func fileIcon(name string) template.HTML { func fileIcon(name string) template.HTML {
ext := strings.ToLower(path.Ext(name)) ext := strings.ToLower(path.Ext(name))
switch ext { switch ext {
case ".canvas":
return iconCanvas
case ".md", ".pdf": case ".md", ".pdf":
return iconDoc return iconDoc
case canvasExt:
return iconCanvas
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
return iconImage return iconImage
case ".mp4", ".mkv", ".avi", ".mov": case ".mp4", ".mkv", ".avi", ".mov":
+18 -111
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"html/template"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@@ -13,7 +12,6 @@ import (
"sync" "sync"
"time" "time"
"unicode" "unicode"
"unicode/utf8"
) )
// searchSectionCap bounds how many rows each results section renders. The // searchSectionCap bounds how many rows each results section renders. The
@@ -48,10 +46,14 @@ type searchPageData struct {
PageTotal int PageTotal int
FileTotal int FileTotal int
IndexBuiltAt time.Time IndexBuiltAt time.Time
Tree template.HTML // sidebar folder tree RenderMS int64
renderTimer
} }
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
// results page is always ordinary chrome — it is never the editor and never a
// canvas — but EditMode is honoured so the two views cannot drift apart.
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
// indexEntry is the shared scoreable core of both folder and file index // indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so // entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization // the per-query scoring loop avoids redoing the lowercasing and tokenization
@@ -133,11 +135,10 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
PageTotal: len(pages), PageTotal: len(pages),
FileTotal: len(files), FileTotal: len(files),
IndexBuiltAt: builtAt, IndexBuiltAt: builtAt,
Tree: sidebarTree(h.root, "/"),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.renderTimer = renderTimer{requestStart(r)} data.RenderMS = elapsedMS(r)
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil { if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("search template error: %v", err) log.Printf("search template error: %v", err)
} }
} }
@@ -282,7 +283,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 { if best < 20 {
best = 20 best = 20
} }
case fuzzy && withinTwoEdits(w, qt): case fuzzy && levenshtein(w, qt) <= 2:
if best < 5 { if best < 5 {
best = 5 best = 5
} }
@@ -293,23 +294,20 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
return score return score
} }
type suggestResult struct { // handleSearchSuggest serves the JSON typeahead for the header dropdown and
Name string `json:"name"` // the editor's link picker. Caps results at 5; reports total so the UI can
Path string `json:"path"` // surface a "show all" footer when more matches exist. Empty/whitespace query
URL string `json:"url"` // is a no-op (200 with empty results), not a 400 — every keystroke fires this.
}
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
// (header search, editor link dialog; htmx requests) and JSON for the
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
// can offer a "show all" footer (?footer) when more matches exist. An
// empty/whitespace query is a no-op (200, no results), not a 400 — every
// keystroke fires this.
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) { if !h.checkAuth(w, r) {
return return
} }
query := strings.TrimSpace(r.URL.Query().Get("q")) query := strings.TrimSpace(r.URL.Query().Get("q"))
type suggestResult struct {
Name string `json:"name"`
Path string `json:"path"`
URL string `json:"url"`
}
type suggestResp struct { type suggestResp struct {
Query string `json:"query"` Query string `json:"query"`
Results []suggestResult `json:"results"` Results []suggestResult `json:"results"`
@@ -331,92 +329,12 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
}) })
} }
} }
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
// editor's [[ autocomplete) keep the JSON.
w.Header().Set("Vary", "HX-Request")
if isHX(r) {
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("search suggest encode error: %v", err) log.Printf("search suggest encode error: %v", err)
} }
} }
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
// suggestMinLen is the shortest query (in characters) the typeahead answers;
// shorter ones render nothing, which closes the dropdown.
const suggestMinLen = 2
type suggestRow struct {
Name, Path, URL string
Highlighted template.HTML
}
// renderSuggest writes the typeahead dropdown for query. footer adds a
// "Show all" row linking to the full results page when some were cut off.
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
data := struct {
Show bool
Results []suggestRow
Total int
AllURL string
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
tokens := suggestTokens(query)
for _, res := range results {
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
}
if footer && total > len(results) {
data.AllURL = "/?q=" + url.QueryEscape(query)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
log.Printf("suggest template error: %v", err)
}
}
// suggestTokens lowercases query and splits it on anything that is not a
// letter or digit.
func suggestTokens(query string) []string {
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
}
// highlightMatches bolds the first occurrence of each token in name
// (case-insensitive), merging overlapping spans, and escapes the rest.
func highlightMatches(name string, tokens []string) template.HTML {
lower := strings.ToLower(name)
if len(lower) != len(name) {
// Lowercasing changed byte lengths, so offsets would not line up.
return template.HTML(template.HTMLEscapeString(name))
}
type span struct{ from, to int }
var spans []span
for _, t := range tokens {
if i := strings.Index(lower, t); i >= 0 {
spans = append(spans, span{i, i + len(t)})
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
var b strings.Builder
cursor := 0
for i := 0; i < len(spans); i++ {
sp := spans[i]
for i+1 < len(spans) && spans[i+1].from <= sp.to {
i++
sp.to = max(sp.to, spans[i].to)
}
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
cursor = sp.to
}
b.WriteString(template.HTMLEscapeString(name[cursor:]))
return template.HTML(b.String())
}
// handleReindex rebuilds the folder index synchronously and returns 204. // handleReindex rebuilds the folder index synchronously and returns 204.
// The frontend reloads the page on success. Serialized via buildMu so a // The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel. // double-click waits rather than running two walks in parallel.
@@ -662,17 +580,6 @@ func tokenize(s string) []string {
return tokens return tokens
} }
// withinTwoEdits reports whether a and b are at most 2 edits apart. Lengths
// differing by more than 2 rule that out without running levenshtein, which
// skips nearly every word pair in the per-query scoring loop.
func withinTwoEdits(a, b string) bool {
d := utf8.RuneCountInString(a) - utf8.RuneCountInString(b)
if d < -2 || d > 2 {
return false
}
return levenshtein(a, b) <= 2
}
// levenshtein returns the edit distance between a and b. Operates on runes so // levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit. // multi-byte characters count as one edit.
func levenshtein(a, b string) int { func levenshtein(a, b string) int {
-135
View File
@@ -1,135 +0,0 @@
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
@@ -1,111 +0,0 @@
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
@@ -1,21 +0,0 @@
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)
}
}
}
+6 -7
View File
@@ -17,19 +17,18 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
// then a `[ ]` / `[x]` / `[X]` checkbox marker. // then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`) var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle // rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
// their source line. The disabled attribute is removed and each checkbox // HTML so JS can wire them up. Each checkbox gains a data-task-index matching
// posts itself to ?toggle=N (htmx), N being its position among task list // its position among task list items in source order; the disabled attribute
// items in source order. An unchecked box sends no value, i.e. checked=false; // is removed so the user can toggle them.
// a failed save flips the box back.
func rewriteTaskCheckboxes(in []byte) []byte { func rewriteTaskCheckboxes(in []byte) []byte {
idx := 0 idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte { return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked")) checked := bytes.Contains(match, []byte("checked"))
var out bytes.Buffer var out bytes.Buffer
out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`) out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
out.WriteString(strconv.Itoa(idx)) out.WriteString(strconv.Itoa(idx))
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"`) out.WriteByte('"')
if checked { if checked {
out.WriteString(` checked=""`) out.WriteString(` checked=""`)
} }
+3 -40
View File
@@ -12,7 +12,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -51,11 +50,6 @@ var (
digestCache = map[string]digestEntry{} digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex digestCacheMu sync.Mutex
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
// photos otherwise decodes one full-size image per browser connection at
// once, which on the NAS means memory pressure and CPU thrash.
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
) )
// digestEntry remembers the digest of a source file so repeated requests do // digestEntry remembers the digest of a source file so repeated requests do
@@ -185,10 +179,7 @@ func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS s
return return
} }
thumbGenSlots <- struct{}{} if err := generateThumb(cacheFS, write); err != nil {
err = generateThumb(cacheFS, write)
<-thumbGenSlots
if err != nil {
log.Printf("thumb %s: %v", rel, err) log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError) http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return return
@@ -208,9 +199,6 @@ func pathDigest(srcFS string, info os.FileInfo) string {
// On a cache hit (path + mtime + size unchanged) the returned data is nil, // On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read // so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse. // once and the contents are returned for the caller to reuse.
//
// Hits come from memory or, after a restart, from the on-disk index (see
// digestIndexPath), so an unchanged file is hashed only once ever.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) { func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock() digestCacheMu.Lock()
d, ok := digestCache[srcFS] d, ok := digestCache[srcFS]
@@ -219,43 +207,18 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
return d.hex, nil, nil return d.hex, nil, nil
} }
indexFS := digestIndexPath(srcFS, info)
if b, err := os.ReadFile(indexFS); err == nil && len(b) == sha256.Size*2 {
h := string(b)
rememberDigest(srcFS, info, h)
return h, nil, nil
}
data, err := os.ReadFile(srcFS) data, err := os.ReadFile(srcFS)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:]) h := hex.EncodeToString(sum[:])
rememberDigest(srcFS, info, h)
// Best effort: a failed write only costs a re-hash after the next restart.
if err := os.MkdirAll(filepath.Dir(indexFS), 0755); err == nil {
if err := writeFileAtomic(indexFS, []byte(h), 0644); err != nil {
log.Printf("thumb digest index %s: %v", indexFS, err)
}
}
return h, data, nil
}
// digestIndexPath is where the content digest of srcFS is persisted, keyed on
// path + mtime + size like pathDigest, so overwriting the file misses and
// re-hashes. Entries for changed or deleted files are left behind; each is 64
// bytes.
func digestIndexPath(srcFS string, info os.FileInfo) string {
key := pathDigest(srcFS, info)
return filepath.Join(thumbCacheDir, "digests", key[:2], key)
}
func rememberDigest(srcFS string, info os.FileInfo, h string) {
digestCacheMu.Lock() digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h} digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock() digestCacheMu.Unlock()
return h, data, nil
} }
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) { func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
+12 -39
View File
@@ -57,14 +57,6 @@ func resizeBox(src image.Image, width int) image.Image {
} }
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
// JPEGs decode to *image.YCbCr. Reading its planes directly avoids an
// interface call and a colour conversion per source pixel, which makes the
// resize ~4x faster; everything else takes the generic At() path.
avg := boxAvg
if ycc, ok := src.(*image.YCbCr); ok {
avg = func(_ image.Image, r image.Rectangle) color.RGBA { return boxAvgYCbCr(ycc, r) }
}
for y := 0; y < dstH; y++ { for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH sy1 := (y + 1) * srcH / dstH
@@ -77,42 +69,23 @@ func resizeBox(src image.Image, width int) image.Image {
if sx1 == sx0 { if sx1 == sx0 {
sx1 = sx0 + 1 sx1 = sx0 + 1
} }
box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min) var r, g, bl, n uint64
dst.SetRGBA(x, y, avg(src, box)) 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()
return dst r += uint64(sr >> 8)
}
// 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) g += uint64(sg >> 8)
bl += uint64(sb >> 8) bl += uint64(sb >> 8)
n++ n++
} }
} }
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255} dst.SetRGBA(x, y, color.RGBA{
} R: uint8(r / n),
G: uint8(g / n),
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly. B: uint8(bl / n),
// Each pixel is still converted to RGB before averaging so out-of-gamut A: 255,
// 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} return dst
} }
-89
View File
@@ -1,89 +0,0 @@
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)
}
}
+53 -168
View File
@@ -1,71 +1,30 @@
package main package main
import ( import (
"bytes" "encoding/json"
"html/template"
"log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
) )
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
// treeOpts selects what a folder tree renders. The same markup serves the
// sidebar (links) and the tree picker (radio inputs named "pick").
type treeOpts struct {
Files bool // list files, not just folders
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
Active string // wiki path of the current page, highlighted (sidebar)
Select string // wiki path of the initially checked row (picker)
}
// query returns the ?tree query that lazy-loads a folder's children with the
// same options.
func (o treeOpts) query() string {
q := "?tree"
if o.Files {
q += "&files=1"
}
if o.Pick != "" {
q += "&pick=" + o.Pick
}
return q
}
type treeEntry struct { type treeEntry struct {
Name string Name string `json:"name"`
Kind string // "folder" or "file" Kind string `json:"kind"`
// HasChildren reports whether a folder has any visible entries (under the // Children is populated only along the expandTo chain (see handleTree);
// same files filter), so empty folders render without a chevron. // omitted otherwise so the flat picker listing keeps its original shape.
HasChildren bool Children []treeEntry `json:"children,omitempty"`
} }
// treeRow is one rendered row. Folders with children are expandable: either type treeResponse struct {
// pre-expanded (Children set, along the requested path) or lazy-loaded from Path string `json:"path"`
// ChildrenURL when first opened. Entries []treeEntry `json:"entries"`
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 the rendered rows of the folder at fsPath: // 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
// ?tree sidebar rows (folders only, links) // descended — the client lazy-loads children on expand.
// &files=1 include files
// &pick=folder|file|any picker rows selecting that kind
// &expandTo=/a/b pre-expand the chain down to /a/b
// &select=/a/b check that row
// &root=1 lead with a row for "/" itself (picker)
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
@@ -81,98 +40,30 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
q := r.URL.Query() foldersOnly := r.URL.Query().Has("foldersOnly")
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
}
dir := normalizeMovePath(urlPath) entries, err := listTreeEntries(fsPath, foldersOnly)
var expand []string
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
expand = segs[len(base):]
}
rows, err := buildTreeRows(fsPath, dir, expand, o)
if err != nil { if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError) http.Error(w, "read failed", http.StatusInternalServerError)
return return
} }
if dir == "/" && q.Has("root") && o.Pick != "" {
root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o) if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
rows = append([]treeRow{root}, rows...) expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
}
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)
} }
// sidebarTree renders the navigation tree for the page at urlPath: the root // canonicalTreePath returns the URL path in the form used by the picker:
// listing with the chain down to the page expanded and the page highlighted. // "/" for root, otherwise stripped of any trailing slash.
// The tree is part of the page itself, so the rail never paints empty. func canonicalTreePath(urlPath string) string {
func sidebarTree(root, urlPath string) template.HTML { if urlPath == "" || urlPath == "/" {
page := normalizeMovePath(urlPath) return "/"
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
if err != nil {
return ""
} }
var buf bytes.Buffer return "/" + strings.Trim(urlPath, "/")
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
log.Printf("tree template error: %v", err)
return ""
}
return template.HTML(buf.String())
}
// buildTreeRows lists the folder fsPath (wiki path dir) and expands the
// folder named by expand[0], recursing down the chain. The walk stops at the
// deepest segment that still exists. Segments only ever match real directory
// names from listTreeEntries, so this cannot traverse outside the tree.
func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
entries, err := listTreeEntries(fsPath, !o.Files)
if err != nil {
return nil, err
}
rows := make([]treeRow, len(entries))
for i, e := range entries {
p := path.Join(dir, e.Name)
rows[i] = newTreeRow(e, p, o)
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
if err == nil {
rows[i].Children = kids
}
}
}
return rows, nil
}
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
row := treeRow{
Name: e.Name,
Path: p,
Kind: e.Kind,
URL: pageURL(p),
Link: o.Pick == "",
Active: o.Pick == "" && p == o.Active,
Expandable: e.HasChildren,
}
if e.Kind == "file" {
row.URL = fileURL(strings.TrimPrefix(p, "/"))
}
if o.Pick != "" {
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
row.Checked = row.Selectable && p == o.Select
}
if row.Expandable {
row.ChildrenURL = row.URL + o.query()
}
return row
} }
// listTreeEntries returns the immediate children of fsPath, filtering hidden // listTreeEntries returns the immediate children of fsPath, filtering hidden
@@ -190,11 +81,7 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
continue continue
} }
if e.IsDir() { if e.IsDir() {
folders = append(folders, treeEntry{ folders = append(folders, treeEntry{Name: name, Kind: "folder"})
Name: name,
Kind: "folder",
HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly),
})
} else { } else {
if foldersOnly || name == "index.md" { if foldersOnly || name == "index.md" {
continue continue
@@ -207,33 +94,6 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
return append(folders, files...), nil return append(folders, files...), nil
} }
// folderHasChildren reports whether fsPath contains at least one entry that
// listTreeEntries would list. It reads the directory in small batches and
// stops at the first match, so large folders stay cheap. Unreadable folders
// report false.
func folderHasChildren(fsPath string, foldersOnly bool) bool {
f, err := os.Open(fsPath)
if err != nil {
return false
}
defer f.Close()
for {
batch, err := f.ReadDir(32)
for _, e := range batch {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if e.IsDir() || (!foldersOnly && name != "index.md") {
return true
}
}
if err != nil {
return false
}
}
}
// treePathSegments splits a wiki path into its non-empty segments. // treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string { func treePathSegments(p string) []string {
var segs []string var segs []string
@@ -244,3 +104,28 @@ func treePathSegments(p string) []string {
} }
return segs return segs
} }
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
-40
View File
@@ -1,40 +0,0 @@
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)
}
}