Compare commits

..
1 Commits
Author SHA1 Message Date
luxick b48d476b39 JSON canvas initial 2026-09-22 21:28:28 +02:00
87 changed files with 4794 additions and 4576 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
+147 -83
View File
@@ -4,126 +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.
- **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, `N` new page, `M` move, `F` search, `H` maintenance |
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
## Diary
``` ```
Diary/ type = diary
.page-settings ← type = diary
2026/
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
2026-05-11 Beach.jpg ← photos go under their date's section
``` ```
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. ### Diary
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`. 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.
## Fitness
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
## Calendars
``` ```
Calendars/ FolderName/
.page-settings ← type = calendars, roles, sync credentials .page-settings ← type = diary
Personal.ics ← one file per calendar YYYY/
Gewicht.ics index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
… YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
``` ```
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. 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.
``` A sidebar calendar widget shows one month grid at a time; the month-name
type = calendars 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.
# Roles: file names without .ics #### Persistent date links
diary = Personal, Feiertage
weight = Gewicht
maintenance = Maintainance
# Sync (Nextcloud) Each diary root exposes three stable paths intended for browser bookmarks.
caldav-url = https://cloud.example/remote.php/dav/calendars/me/ They resolve against the year page rather than separate per-day URLs:
caldav-user = me
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
sync-interval = 15m
```
| Key | | | Path | Redirects to |
|-----|-| |------|-------------|
| `diary` | Events shown under each diary day: all-day first, then by start time | | `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) |
| `weight` | Titles like `95,6kg`, shown under the diary day's events | | `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page | | `<diary>/this-year/` | `<diary>/YYYY/` |
| `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. Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar. ## Canvases
## Quick-add bookmarklet A canvas is a `<Name>.canvas` file in a page folder holding a
[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.
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page: | URL | What it does |
|------|-------------|
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
| `<page>/<Name>.canvas` | redirects to the canonical form |
| `<page>/<Name>.canvas?raw` | the file itself |
Create one from the **NEW CANVAS** action or the `+` in the tab strip.
### Nodes
All four spec node types render:
| 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 |
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.
### 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>
+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; }
};
})();
+88
View File
@@ -0,0 +1,88 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}" title="Back to the page">PAGE</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}}
{{define "content"}}
<script>
// Full-bleed view mode: the canvas surface owns the viewport instead of the
// centred reading column. Set here rather than server-side for the same
// reason the editor does it — one class, no extra template plumbing.
document.body.classList.add('canvas-mode');
</script>
{{if .CanvasError}}
<div class="content">
<h1>{{.ActiveCanvas}}</h1>
<p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
<p>Fix it in the file itself, then reload.
<a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
<a href="{{.PostURL}}">back to the page</a></p>
</div>
{{else}}
<div class="canvas-app">
<nav class="canvas-tabs row" aria-label="Page views">
<a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
{{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
</nav>
<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}}
+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();
})();
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
};
})();
+4 -11
View File
@@ -200,20 +200,13 @@
// 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 updateFooterIcon();
applyToPage(); wireFileLinks();
// Boosted navigation swaps in a new page without reloading this wireRevealButton();
// script: re-apply to its footer icon and reveal buttons. wireFileRevealButtons();
document.addEventListener('page:swapped', applyToPage);
}); });
} }
function applyToPage() {
updateFooterIcon();
wireRevealButton();
wireFileRevealButtons();
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
+1 -1
View File
@@ -24,4 +24,4 @@
</table> </table>
{{end}} {{end}}
</div> </div>
<script src="{{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}}
+6 -6
View File
@@ -78,10 +78,10 @@
<div id="editor" class="editor-cm"></div> <div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea> <textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form> </form>
<script src="{{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/main.js"}}"></script> <script src="/_/editor/main.js"></script>
{{end}} {{end}}
+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);
} }
+6 -76
View File
@@ -1,80 +1,10 @@
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one // Fitness dashboard range dropdowns: changing one reloads the page with that
// reloads the page with that chart's query parameter updated. Plain GET // chart's query parameter updated. Plain GET navigation — each range is a
// navigation — each view is distinct and bookmarkable, so no // distinct, bookmarkable view, so no postReplace/history handling is needed.
// postReplace/history handling is needed.
// Boosted navigation re-runs this script on every visit; the listeners
// are delegated to document, so register them once.
if (!window.fitnessWired) {
window.fitnessWired = true;
document.addEventListener('change', function (e) { document.addEventListener('change', function (e) {
var url = new URL(window.location.href);
var sel = e.target.closest('[data-fitness-range]'); var sel = e.target.closest('[data-fitness-range]');
var abs = e.target.closest('[data-fitness-absolute]'); if (!sel) return;
if (sel) { var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value); url.searchParams.set(sel.dataset.fitnessRange, sel.value);
} else if (abs) {
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
} else {
return;
}
window.location.href = url.toString(); window.location.href = url.toString();
}); });
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
// are sorted by x, so a binary search finds it; the tooltip sits above the
// point and is clamped to the chart panel.
function fitnessNearest(dots, x) {
var lo = 0, hi = dots.length - 1;
while (lo < hi) {
var mid = (lo + hi) >> 1;
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
}
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
return dots[lo];
}
function fitnessHide(svg) {
var chart = svg.closest('.fitness-chart');
svg.classList.remove('is-hover');
chart.querySelector('.fitness-tip').hidden = true;
}
document.addEventListener('pointermove', function (e) {
var svg = e.target.closest && e.target.closest('.fitness-svg');
document.querySelectorAll('.fitness-svg').forEach(function (other) {
if (other !== svg) fitnessHide(other);
});
if (!svg) return;
var dots = svg.querySelectorAll('.chart-dot');
if (!dots.length) return;
var ctm = svg.getScreenCTM();
if (!ctm) return;
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
var dot = fitnessNearest(dots, pt.x);
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
var cursor = svg.querySelector('.chart-cursor');
cursor.setAttribute('x1', cx);
cursor.setAttribute('x2', cx);
var focus = svg.querySelector('.chart-focus');
focus.setAttribute('cx', cx);
focus.setAttribute('cy', cy);
svg.classList.add('is-hover');
var chart = svg.closest('.fitness-chart');
var tip = chart.querySelector('.fitness-tip');
tip.firstElementChild.textContent = dot.dataset.date;
tip.lastElementChild.textContent = dot.dataset.value;
tip.hidden = false;
var box = chart.getBoundingClientRect();
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
var left = at.x - box.left - tip.offsetWidth / 2;
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
var top = at.y - box.top - tip.offsetHeight - 10;
if (top < 0) top = at.y - box.top + 10;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
});
}
+6 -13
View File
@@ -2,23 +2,19 @@
<section class="fitness-chart panel"> <section class="fitness-chart panel">
<div class="fitness-chart-header row space-between"> <div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span> <span class="caption">{{.Title}}</span>
<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" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label> {{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range"> </select>
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
</div> </div>
{{if .Empty}} {{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p> <p class="fitness-empty is-empty">No data in this range.</p>
{{else}} {{else}}
<svg class="fitness-svg{{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}}"/>
@@ -27,16 +23,13 @@
<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}}
@@ -47,4 +40,4 @@
{{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>
+7 -15
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,14 +58,12 @@ 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'); if (!menu) return;
if (!menu) return; 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);
});
}); });
}); });
-140
View File
@@ -1,140 +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';
}
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
// target into the current history entry rather than pushing a new one, so
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
// navigation; elsewhere replaceState + reload (not location.replace: a
// target differing only by #fragment would otherwise not re-fetch).
htmx.location = {
set href(target) {
if (boosting()) {
if (window.closeModal) closeModal();
htmx.ajax('GET', target, {
target: '.shell',
swap: 'innerHTML transition:true',
headers: { 'HX-Boosted': 'true' },
replace: target
});
return;
}
window.history.replaceState(null, '', target);
window.location.reload();
},
reload: function () { window.location.reload(); }
};
// Failed requests answer with a plain-text reason; show it. A failed
// boosted navigation falls back to loading the page normally instead.
function fallBack(e) {
if (!e.detail.boosted) return false;
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
return true;
}
document.addEventListener('htmx:responseError', function (e) {
if (fallBack(e)) return;
var xhr = e.detail.xhr;
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
});
document.addEventListener('htmx:sendError', function (e) {
if (fallBack(e)) return;
alert('Network error');
});
// --- Boosted navigation ---------------------------------------------
// Outside the editor, links and GET forms to wiki pages load the next page
// into .shell instead of replacing the document: the server answers with
// the "partial" (title, page actions out-of-band, shell content). The
// header stays put, nothing flashes in browsers without cross-document
// view transitions, and the swap runs as a same-document view transition.
// Back/Forward refetch the partial (hx-history="false" on .shell: no
// snapshots that could show a page from before a move or delete).
htmx.config.scrollIntoViewOnBoost = false;
// Only wiki pages are boosted: same origin, a slash-terminated path, no
// query except a search (?q=). Files, the editor (?edit), internal
// endpoints (/_…) and POST forms navigate normally.
function isPageURL(url) {
if (url.origin !== location.origin) return false;
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
var ok = true;
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
return ok;
}
// htmx decides per element whether it is boosted, right after this event.
document.addEventListener('htmx:beforeProcessNode', function (e) {
var elt = e.target;
if (elt.hasAttribute('hx-boost')) return;
var page = false;
if (elt.tagName === 'A') {
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
} else if (elt.tagName === 'FORM') {
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
} else {
return;
}
if (!page) elt.setAttribute('hx-boost', 'false');
});
function isShell(elt) {
return elt && elt.classList && elt.classList.contains('shell');
}
// Scroll positions: the tree rail keeps its position across navigation;
// the content column (.center) starts at the top, or where it was when
// coming back via Back/Forward.
var treeTop = 0;
var restoring = false;
var centerTops = {};
function pageKey() {
return location.pathname + location.search;
}
function rememberTree() {
var tree = document.querySelector('.tree-scroll');
treeTop = tree ? tree.scrollTop : 0;
}
document.addEventListener('scroll', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
}, { capture: true, passive: true });
document.addEventListener('htmx:beforeSwap', function (e) {
if (e.detail.boosted) {
e.detail.target = document.querySelector('.shell');
e.detail.swapOverride = 'innerHTML transition:true';
}
if (isShell(e.detail.target)) rememberTree();
});
document.addEventListener('htmx:historyCacheMissLoad', function () {
restoring = true;
rememberTree();
});
// After a new page is in .shell: restore scroll positions, then tell page
// scripts (companion.js, …) to apply themselves to the new content.
document.addEventListener('htmx:afterSettle', function (e) {
if (!isShell(e.target)) return;
var tree = document.querySelector('.tree-scroll');
if (tree) {
tree.scrollTop = treeTop;
var active = tree.querySelector('.tree-row.is-active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
if (restoring) {
restoring = false;
var center = document.querySelector('.center');
if (center) center.scrollTop = centerTops[pageKey()] || 0;
}
document.dispatchEvent(new CustomEvent('page:swapped'));
});
})();
@@ -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" stroke-linecap="square"> fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/> <path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 241 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="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

+36 -76
View File
@@ -4,103 +4,63 @@
<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. */}}
<body{{if not .EditMode}} hx-boost="true"{{end}}>
<header> <header>
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
</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">
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}} {{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{/* The page-specific header actions; the partial swaps them out-of-band
(the attribute is inert in a full page). */}}
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
<div class="actions-special">
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
</div>
{{template "headerActions" .}}
</div>{{end}}
{{define "shell"}}
{{if not .EditMode}}<aside class="tree-sidebar col">
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
<script>
// Full page loads: keep the rail's scroll position (before first
// paint, so it doesn't jump), then reveal the active row. Boosted
// navigation keeps it itself (hx.js); this script re-runs then and
// bails out.
(function () {
if (document.readyState !== "loading") return;
var el = document.currentScript.previousElementSibling;
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
var active = el.querySelector(".tree-row.is-active");
if (active) active.scrollIntoView({ block: "nearest" });
addEventListener("pagehide", function () {
var cur = document.querySelector(".tree-scroll");
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
});
})();
</script>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
<span class="dropdown companion-status" data-companion-status hidden>
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
{{end}}
{{/* Response to boosted navigation and history restores (hx.js swaps it into
.shell): the title, the page actions out-of-band, and the shell's content. */}}
{{define "partial"}}<title>{{.Title}}</title>
{{template "page-actions" .}}
{{template "shell" .}}{{end}}
+136 -144
View File
@@ -1,111 +1,120 @@
// 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;
// show mounts backdrop (whose child is the .modal) into parent and makes function build() {
// it the current modal. onKeydown handles keys beyond Escape. backdrop = document.createElement('div');
function show(backdrop, parent, prevFocus, onKeydown) {
var modal = backdrop.querySelector('.modal');
var header = modal.querySelector('.panel-header');
function onDown(e) { startDrag(e, modal); }
header.addEventListener('mousedown', onDown);
header.addEventListener('touchstart', onDown, { passive: false });
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
backdrop.addEventListener('click', function (e) {
if (e.target.closest('[data-modal-close]')) close();
});
var keydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (onKeydown) onKeydown(e);
};
document.addEventListener('keydown', keydown);
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
}
// Dragging switches the modal to fixed positioning so flexbox alignment
// doesn't fight us.
function startDrag(e, modal) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
e.preventDefault();
}
function onMove(e) {
if (!drag) return;
var pt = e.touches ? e.touches[0] : e;
var m = drag.modal;
var maxX = window.innerWidth - m.offsetWidth;
var maxY = window.innerHeight - m.offsetHeight;
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
}
function onUp() { drag = null; }
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
// detach tears down the current modal and returns the element that had
// focus before it opened. keepDOM leaves the backdrop in place for a swap
// about to replace it: htmx must still find the element that issued the
// request in the document.
function detach(keepDOM) {
if (!current) return null;
var c = current;
current = null;
drag = null;
document.removeEventListener('keydown', c.onKeydown);
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
return c.prevFocus;
}
function close() {
var toRestore = detach();
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
function open(opts) {
var prevFocus = current ? detach() : document.activeElement;
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop'; backdrop.className = 'modal-backdrop';
var modal = document.createElement('div');
modal = document.createElement('div');
modal.className = 'modal panel panel-floating'; modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog'); modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div'); var header = document.createElement('div');
header.className = 'panel-header'; header.className = 'panel-header';
var titleEl = document.createElement('span'); titleEl = document.createElement('span');
titleEl.textContent = opts.title || '';
header.appendChild(titleEl); header.appendChild(titleEl);
var bodyEl = document.createElement('div'); bodyEl = document.createElement('div');
bodyEl.className = 'panel-body'; bodyEl.className = 'panel-body';
footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
wireDrag(header);
cancelBtn.addEventListener('click', close);
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
currentOpts.confirm.onConfirm();
}
});
}
// wireDrag makes the modal draggable by `handle`. Dragging switches the
// modal to fixed positioning so flexbox alignment doesn't fight us.
function wireDrag(handle) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
dragging = true;
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var pt = e.touches ? e.touches[0] : e;
var dx = pt.clientX - startX;
var dy = pt.clientY - startY;
var w = modal.offsetWidth;
var h = modal.offsetHeight;
var maxX = window.innerWidth - w;
var maxY = window.innerHeight - h;
var x = Math.max(0, Math.min(maxX, originX + dx));
var y = Math.max(0, Math.min(maxY, originY + dy));
modal.style.left = x + 'px';
modal.style.top = y + 'px';
}
function onUp() { dragging = false; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
}
function open(opts) {
if (backdrop && backdrop.parentNode) close();
if (!backdrop) build();
currentOpts = opts;
prevFocus = document.activeElement;
modal.classList.remove('is-dragged');
modal.style.position = '';
modal.style.left = '';
modal.style.top = '';
titleEl.textContent = opts.title || '';
bodyEl.textContent = '';
if (opts.body instanceof Node) { if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body); bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') { } else if (typeof opts.body === 'string') {
@@ -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');
}
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();
} }
}); backdrop.parentNode.removeChild(backdrop);
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
window.openModal = open; window.openModal = open;
window.closeModal = close; window.closeModal = close;
+116 -7
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';
closeModal(); input.className = 'input';
htmx.ajax('GET', window.location.pathname + '?dialog=move', { input.placeholder = 'Page name';
target: '#modal-host', input.value = currentName;
values: { parent: newParent }
openModal({
title: 'Move — new name?',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
}
}
}); });
} }
}); });
} }
function deletePage() {
var decodedPath = decodeURIComponent(window.location.pathname);
openModal({
title: 'Delete page',
body: 'Delete ' + decodedPath + ' and everything inside it?',
confirm: {
label: 'DELETE',
danger: true,
enterConfirms: false,
onConfirm: function () {
var p = window.location.pathname.replace(/\/+$/, '');
var idx = p.lastIndexOf('/');
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
closeModal();
postReplace(window.location.pathname + '?delete=1', null, parent);
}
},
cancel: { autofocus: true },
swapButtons: true
});
}
+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);
}
}
});
}
+14 -20
View File
@@ -1,21 +1,15 @@
// 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). var aside = document.querySelector("aside.sidebar");
(function () { var specials = document.querySelector(".actions-special");
function run() { if (!aside || !aside.children.length || !specials) return;
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
var item = document.createElement("button"); var item = document.createElement("button");
item.type = "button"; item.type = "button";
item.className = "btn btn-block"; item.className = "btn btn-block";
item.title = "Contents"; item.title = "Contents";
item.textContent = "CONTENTS"; item.textContent = "CONTENTS";
item.addEventListener("click", function () { item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside); if (typeof openOverlay === "function") openOverlay(aside);
}); });
specials.appendChild(item); specials.appendChild(item);
} });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
-80
View File
@@ -1,80 +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 "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 -13
View File
@@ -1,25 +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" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button> <button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</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}}
@@ -42,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;
});
});
});
})();
+27 -33
View File
@@ -1,38 +1,32 @@
// 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). var content = document.querySelector("main");
(function () { if (!content) return;
function run() {
var content = document.querySelector("main");
if (!content) return;
var headings = content.querySelectorAll("h2, h3, h4"); var headings = content.querySelectorAll("h2, h3, h4");
if (headings.length < 2) return; if (headings.length < 2) return;
var nav = document.createElement("nav"); var nav = document.createElement("nav");
nav.className = "toc panel"; nav.className = "toc panel";
var header = document.createElement("div"); var header = document.createElement("div");
header.className = "panel-header"; header.className = "panel-header";
header.textContent = "Contents"; header.textContent = "Contents";
nav.appendChild(header); nav.appendChild(header);
var list = document.createElement("ul"); var list = document.createElement("ul");
headings.forEach(function (h) { headings.forEach(function (h) {
if (!h.id) return; if (!h.id) return;
var li = document.createElement("li"); var li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase(); li.className = "toc-" + h.tagName.toLowerCase();
var a = document.createElement("a"); var a = document.createElement("a");
a.href = "#" + h.id; a.href = "#" + h.id;
var clone = h.cloneNode(true); var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); }); clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim(); a.textContent = clone.textContent.trim();
li.appendChild(a); li.appendChild(a);
list.appendChild(li); list.appendChild(li);
}); });
nav.appendChild(list); nav.appendChild(list);
content.insertBefore(nav, content.firstChild); content.insertBefore(nav, content.firstChild);
} });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+85
View File
@@ -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; },
});
}); });
})(); })();
+12 -21
View File
@@ -52,19 +52,19 @@ function createSearchPage(name) {
}); });
} }
// The "create page" link on results pages; delegated, since boosted
// navigation swaps the results in without a new DOMContentLoaded.
document.addEventListener('click', function (e) {
var createLink = e.target.closest && e.target.closest('[data-create-page]');
if (!createLink) return;
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM order,
// so the natural tab sequence is input → first result → next, …
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM
// order, so the natural tab sequence is input → first result → next, …
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (input && input.value) { if (input && input.value) {
input.focus(); input.focus();
@@ -72,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}}
+338 -122
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);
@@ -48,28 +59,6 @@
--reading-width: 60rem; --reading-width: 60rem;
} }
/* Cross-document view transitions: page navigations stay full reloads, but the
browser holds the old frame until the new page is ready and swaps them
without a blank flash. The header and tree rail are named so they swap in
place (no crossfade) — only the rest of the page fades, and only briefly. */
@view-transition { navigation: auto; }
body > header { view-transition-name: app-header; }
.tree-sidebar { view-transition-name: tree-sidebar; }
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation-duration: 0.12s; }
::view-transition-group(app-header),
::view-transition-group(tree-sidebar),
::view-transition-old(app-header),
::view-transition-new(app-header),
::view-transition-old(tree-sidebar),
::view-transition-new(tree-sidebar) { animation: none; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none; }
}
/* Anchor / TOC jumps are handled by the .center scroll container's own /* Anchor / TOC jumps are handled by the .center scroll container's own
scroll-padding-top (the header no longer overlaps content in the app-shell), scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */ so no html-level scroll padding is needed. */
@@ -241,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
@@ -425,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
@@ -449,13 +418,11 @@ 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
@@ -751,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);
@@ -857,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);
@@ -910,24 +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; }
/* === Responsive === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -949,9 +851,7 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
@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) {
@@ -1000,3 +900,319 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
.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
}
+699
View File
@@ -0,0 +1,699 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
)
// JSON Canvas (https://jsoncanvas.org/spec/1.0/) support.
//
// A canvas is a `<Name>.canvas` file sitting in a page folder, addressed as an
// alternative *view* of that page rather than as a standalone file:
//
// /Topics/Ideas/?canvas=Architecture the canvas view
// /Topics/Ideas/Architecture.canvas 302 -> the line above
// /Topics/Ideas/Architecture.canvas?raw the file bytes
//
// Routing through the folder keeps the page's identity (breadcrumb, tree,
// header actions) and lets every canvas in a folder render as a tab strip above
// the page content. The redirect mirrors how diary.go collapses its virtual
// month/day URLs onto one canonical form.
//
// The server owns the format: it parses and validates on every read and write,
// re-marshals from its own structs on save, and never writes bytes it has not
// round-tripped through canvasDoc. Keys it does not model are dropped rather
// than preserved — this wiki is the only writer.
const canvasExt = ".canvas"
// canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a
// megabyte is already far past any hand-built diagram and well short of
// anything that could pressure the NAS.
const canvasMaxBytes = 4 << 20
// canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content
// travels at a time.
const canvasRenderMaxBytes = 1 << 20
// Structural caps. These exist so a looping client cannot grow a file until it
// stops loading, not because the format has limits.
const (
canvasMaxNodes = 5000
canvasMaxEdges = 10000
canvasMaxCoord = 1 << 20
)
// canvasNameRe restricts a ?canvas= value to a single safe path component: the
// name is joined onto the folder path, so separators, dot-segments, control
// characters, and leading dots (which would create a hidden file the listing
// filters away) must all be rejected before the filesystem sees it.
var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`)
// canvasHexRe matches the hex form of a canvasColor. The spec also allows the
// preset digits "1".."6"; see validCanvasColor.
var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// Node types defined by the spec.
const (
canvasNodeText = "text"
canvasNodeFile = "file"
canvasNodeLink = "link"
canvasNodeGroup = "group"
)
// canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec,
// so an empty document marshals to `{}`.
type canvasDoc struct {
Nodes []canvasNode `json:"nodes,omitempty"`
Edges []canvasEdge `json:"edges,omitempty"`
}
// canvasNode carries every node variant in one struct. The spec's four types
// share six required attributes and add one to three of their own, so a flat
// struct with omitempty round-trips all of them without the ceremony of a
// custom unmarshaller; validate() enforces which extras a given Type may use.
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color,omitempty"`
Text string `json:"text,omitempty"` // type=text
File string `json:"file,omitempty"` // type=file
Subpath string `json:"subpath,omitempty"` // type=file
URL string `json:"url,omitempty"` // type=link
Label string `json:"label,omitempty"` // type=group
Background string `json:"background,omitempty"` // type=group
BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
FromEnd string `json:"fromEnd,omitempty"` // defaults to "none"
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow"
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
}
// canvasRef is one entry in the page's view switcher.
type canvasRef struct {
Name string
URL string
Active bool
}
// canvasPayload is the single blob inlined into the canvas page. Shipping the
// document *and* its server-rendered node HTML together means opening a canvas
// costs exactly one request no matter how many nodes it holds — the obvious
// alternative (one content request per node on load) fans out badly over the
// mobile/VPN path.
type canvasPayload struct {
Name string `json:"name"`
PostURL string `json:"postUrl"`
Hash string `json:"hash"`
Doc *canvasDoc `json:"doc"`
Rendered map[string]template.HTML `json:"rendered"`
}
// --- parsing and validation ---
// parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are
// ignored rather than rejected so a hand-edited file with a stray attribute
// still opens; they are dropped on the next save.
func parseCanvas(raw []byte) (*canvasDoc, error) {
doc := &canvasDoc{}
if len(strings.TrimSpace(string(raw))) == 0 {
return doc, nil
}
if err := json.Unmarshal(raw, doc); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if err := doc.validate(); err != nil {
return nil, err
}
return doc, nil
}
// validate rejects anything that would render as a broken or invisible canvas.
// It runs on read as well as write: a file edited by hand outside the app gets
// the same diagnosis the editor would give, naming the offending node.
func (c *canvasDoc) validate() error {
if len(c.Nodes) > canvasMaxNodes {
return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes)
}
if len(c.Edges) > canvasMaxEdges {
return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges)
}
ids := make(map[string]bool, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
if n.ID == "" {
return fmt.Errorf("node %d: missing id", i)
}
if ids[n.ID] {
return fmt.Errorf("duplicate node id %q", n.ID)
}
ids[n.ID] = true
switch n.Type {
case canvasNodeText:
// An empty text node is a node the user just created and has not
// typed into yet, so emptiness is allowed here even though the spec
// calls `text` required. A missing *file* or *url*, by contrast, is
// a node that can never render anything.
case canvasNodeFile:
if n.File == "" {
return fmt.Errorf("node %q: file node has no file", n.ID)
}
if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") {
return fmt.Errorf("node %q: subpath must start with #", n.ID)
}
case canvasNodeLink:
if n.URL == "" {
return fmt.Errorf("node %q: link node has no url", n.ID)
}
case canvasNodeGroup:
if n.BackgroundStyle != "" {
switch n.BackgroundStyle {
case "cover", "ratio", "repeat":
default:
return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle)
}
}
case "":
return fmt.Errorf("node %q: missing type", n.ID)
default:
return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type)
}
if n.Width <= 0 || n.Height <= 0 {
return fmt.Errorf("node %q: width and height must be positive", n.ID)
}
if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord ||
abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord {
return fmt.Errorf("node %q: coordinates out of range", n.ID)
}
if !validCanvasColor(n.Color) {
return fmt.Errorf("node %q: bad color %q", n.ID, n.Color)
}
}
edgeIDs := make(map[string]bool, len(c.Edges))
for i := range c.Edges {
e := &c.Edges[i]
if e.ID == "" {
return fmt.Errorf("edge %d: missing id", i)
}
if edgeIDs[e.ID] {
return fmt.Errorf("duplicate edge id %q", e.ID)
}
edgeIDs[e.ID] = true
if !ids[e.FromNode] {
return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode)
}
if !ids[e.ToNode] {
return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode)
}
if !validCanvasSide(e.FromSide) {
return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide)
}
if !validCanvasSide(e.ToSide) {
return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide)
}
if !validCanvasEnd(e.FromEnd) {
return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd)
}
if !validCanvasEnd(e.ToEnd) {
return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd)
}
if !validCanvasColor(e.Color) {
return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color)
}
}
return nil
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// validCanvasColor accepts an absent color, a preset digit "1".."6" (red,
// orange, yellow, green, cyan, purple), or a hex string.
func validCanvasColor(c string) bool {
switch c {
case "", "1", "2", "3", "4", "5", "6":
return true
}
return canvasHexRe.MatchString(c)
}
func validCanvasSide(s string) bool {
switch s {
case "", "top", "right", "bottom", "left":
return true
}
return false
}
func validCanvasEnd(e string) bool {
switch e {
case "", "none", "arrow":
return true
}
return false
}
// canvasHash is the conflict token exchanged with the client. It covers the
// exact bytes on disk, so any edit made outside this editor invalidates it.
func canvasHash(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:16]
}
// --- paths and discovery ---
func canvasPath(fsPath, name string) string {
return filepath.Join(fsPath, name+canvasExt)
}
// canvasBackupPath is the previous-contents copy kept beside a canvas. The dot
// prefix keeps it out of the file listing and the search index.
func canvasBackupPath(fsPath, name string) string {
return filepath.Join(fsPath, "."+name+canvasExt+".bak")
}
// canvasURL builds the canonical view URL for a canvas in the folder at urlPath.
func canvasURL(urlPath, name string) string {
if !strings.HasSuffix(urlPath, "/") {
urlPath += "/"
}
return urlPath + "?canvas=" + url.QueryEscape(name)
}
// canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical
// `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives
// already percent-decoded.
func canvasViewURL(urlPath string) (string, bool) {
base := path.Base(urlPath)
name := strings.TrimSuffix(base, canvasExt)
if name == "" || name == base || !canvasNameRe.MatchString(name) {
return "", false
}
return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true
}
// listCanvases returns the canvases in a folder as view-switcher tabs, sorted
// case-insensitively by name. active marks the one currently being viewed.
func listCanvases(fsPath, urlPath, active string) []canvasRef {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
}
var refs []canvasRef
for _, e := range entries {
name := e.Name()
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) {
continue
}
base := strings.TrimSuffix(name, canvasExt)
if !canvasNameRe.MatchString(base) {
continue
}
refs = append(refs, canvasRef{
Name: base,
URL: canvasURL(urlPath, base),
Active: base == active,
})
}
sort.Slice(refs, func(i, j int) bool {
return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name)
})
return refs
}
// isCanvasFile reports whether a listing entry is a canvas. Used to keep
// canvases out of the Files listing — they are surfaced as tabs above the
// content instead, the same reasoning that hides index.md.
func isCanvasFile(name string) bool {
return strings.HasSuffix(name, canvasExt)
}
// --- server-side node rendering ---
// prerenderNodes renders every node whose content the server owns, keyed by
// node id. Link and group nodes are built entirely in the browser and are
// absent from the map.
func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML {
out := make(map[string]template.HTML, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
switch n.Type {
case canvasNodeText:
out[n.ID] = renderMarkdown([]byte(n.Text))
case canvasNodeFile:
out[n.ID] = h.renderFileNode(n.File, n.Subpath)
}
}
return out
}
// renderFileNode renders a file node's transclusion. Markdown is rendered
// through the shared goldmark instance so wikilinks, embeds, tables, and task
// checkboxes behave exactly as they do on a page; media gets a player or an
// <img> pointed at the existing thumbnail endpoint; anything else degrades to
// a link. A missing target renders visibly broken rather than blank.
func (h *handler) renderFileNode(file, subpath string) template.HTML {
target := "/" + strings.Trim(file, "/")
fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") {
return canvasBrokenRef(file, "outside the wiki")
}
info, err := os.Stat(fsPath)
if err != nil {
return canvasBrokenRef(file, "not found")
}
if info.IsDir() {
// A folder is a page; transclude its markdown.
fsPath = filepath.Join(fsPath, "index.md")
if _, err := os.Stat(fsPath); err != nil {
return canvasBrokenRef(file, "page has no content")
}
return h.renderMarkdownFile(fsPath, file, subpath)
}
name := path.Base(target)
href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/")))
switch {
case isImageFile(name):
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
case isVideoFile(name):
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
case strings.EqualFold(path.Ext(name), ".pdf"):
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
case strings.EqualFold(path.Ext(name), ".md"):
return h.renderMarkdownFile(fsPath, file, subpath)
default:
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
html.EscapeString(name) + `</a></p>`)
}
}
// renderMarkdownFile renders a markdown file, optionally narrowed to the
// section named by subpath (`#Heading`). The narrowing reuses the same section
// machinery the page editor uses, so a heading spans its subsections.
func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML {
raw, err := os.ReadFile(fsPath)
if err != nil {
return canvasBrokenRef(file, "unreadable")
}
if subpath != "" {
section, ok := canvasSubpathSection(raw, subpath)
if !ok {
return canvasBrokenRef(file+subpath, "no such heading")
}
raw = section
}
return renderMarkdown(raw)
}
// canvasSubpathSection narrows raw markdown to the section whose heading
// matches subpath. The match accepts either the heading text (what a human
// types) or goldmark's generated anchor id (what a copied link contains).
func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) {
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
if want == "" {
return raw, true
}
sections := splitSections(raw)
ids := headingIDs(raw)
for i := 1; i < len(sections); i++ {
_, text := sectionHeading(sections[i])
id := ""
if i-1 < len(ids) {
id = ids[i-1]
}
if strings.EqualFold(text, want) || id == want {
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
}
}
return nil, false
}
func canvasBrokenRef(target, reason string) template.HTML {
return template.HTML(`<p class="canvas-broken">` + html.EscapeString(target) +
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
}
// --- request handlers ---
// serveCanvas renders the canvas view of a page. Reached from serveDir when
// the request carries ?canvas=<name>.
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
raw, err := os.ReadFile(canvasPath(fsPath, name))
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
data := pageData{
Title: name + " — " + pageTitle(urlPath),
CanEdit: true,
IsRoot: urlPath == "/",
SectionIndex: -1,
InsertBefore: -1,
PostURL: urlPath,
ActiveCanvas: name,
Canvases: listCanvases(fsPath, urlPath, name),
}
// A parse failure is reported in-page rather than as a bare error: the file
// is hand-editable, so the user needs to see which node is wrong and still
// reach the raw bytes.
doc, parseErr := parseCanvas(raw)
if parseErr != nil {
data.CanvasError = parseErr.Error()
} else {
payload := canvasPayload{
Name: name,
PostURL: urlPath,
Hash: canvasHash(raw),
Doc: doc,
Rendered: h.prerenderNodes(doc),
}
// encoding/json escapes <, >, & and the U+2028/U+2029 line separators,
// so the result is safe to inline verbatim in a <script> block.
blob, err := json.Marshal(payload)
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanvasJSON = template.JS(blob)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
}
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
// removes the canvas rather than the whole page.
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
query := r.URL.Query()
switch {
case query.Has("create"):
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
case query.Has("delete"):
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
case query.Has("rename"):
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
default:
h.handleCanvasSave(w, r, urlPath, fsPath, name)
}
}
// handleCanvasSave replaces a canvas with the posted document. The write is
// gated on four checks in order — parse, validate, version, non-empty — so a
// malformed or stale payload never reaches the file.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
if err != nil {
http.Error(w, "request too large or truncated", http.StatusBadRequest)
return
}
doc, err := parseCanvas(body)
if err != nil {
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
return
}
p := canvasPath(fsPath, name)
current, readErr := os.ReadFile(p)
if readErr != nil && !os.IsNotExist(readErr) {
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
return
}
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
http.StatusConflict)
return
}
// A save must never blank a canvas. An empty document is indistinguishable
// from a client that lost its state, so emptying is only allowed when the
// caller says so explicitly — the same reasoning as the empty-content guard
// on page saves.
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
http.StatusBadRequest)
return
}
}
out, err := json.MarshalIndent(doc, "", "\t")
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
out = append(out, '\n')
if len(current) > 0 {
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
log.Printf("canvas backup %s: %v", p, err)
}
}
if err := writeFileAtomic(p, out, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Hand back the new token so the client can keep editing without reloading.
w.Header().Set("X-Canvas-Version", canvasHash(out))
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
p := canvasPath(fsPath, name)
if _, err := os.Stat(p); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
}
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
if !canvasNameRe.MatchString(newName) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
if newName == name {
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
return
}
dst := canvasPath(fsPath, newName)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
}
// handleCanvasRender renders one node's content on demand. It is stateless and
// takes the content in the request rather than reading it from the canvas file:
// the node being re-rendered has just been edited in the browser and is not on
// disk yet. Everything already on disk arrives pre-rendered with the page.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var out template.HTML
switch r.FormValue("type") {
case canvasNodeFile:
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
case canvasNodeText:
out = renderMarkdown([]byte(r.FormValue("text")))
default:
http.Error(w, "bad node type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, string(out))
}
-84
View File
@@ -1,84 +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 "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);
});
-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>`)
} }
+170 -170
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,15 +34,16 @@ 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
} }
q := 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, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")), Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
SuppressTOC: true, SuppressTOC: true,
} }
} }
@@ -65,20 +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",
}
}
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 {
@@ -88,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 {
@@ -96,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))
} }
@@ -206,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 {
@@ -216,11 +289,10 @@ type fitnessGoalVM struct {
} }
type fitnessChartVM struct { 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
PlotX, PlotY, PlotR, PlotB string PlotX, PlotY, PlotR, PlotB string
@@ -229,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 {
@@ -237,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),
@@ -252,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 {
@@ -275,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 {
@@ -320,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
@@ -358,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())
} }
for _, p := range points { if len(points) <= 100 {
vm.Dots = append(vm.Dots, dot(p)) for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
} }
vm.Dense = len(points) > 100
if hasGoal { if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal)) vm.setGoal(sc, goal, "goal "+svgNum(goal))
@@ -379,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")
}
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
// maxXTicks bounds the label count so labels never collide on the axis.
const maxXTicks = 5
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
// month starts or new years, whichever is the finest step that stays within
// maxXTicks. Labels near the plot edges are anchored inward so they stay
// inside the viewBox.
func timeXTicks(sc chartScale) []fitnessTickVM {
var ticks []time.Time
var step xTickStep
for _, step = range xTickSteps {
ticks = alignedTicks(sc.x0, sc.x1, step)
if len(ticks) <= maxXTicks {
break
} }
} prev = label
out := make([]fitnessTickVM, 0, len(ticks))
for i, t := range ticks {
x := sc.x(t)
anchor := "middle" anchor := "middle"
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)
@@ -482,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),
}} }}
} }
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"os"
"strings"
"path/filepath"
"testing"
"time"
)
func TestFitnessWeights(t *testing.T) {
withBerlin(t)
dir := t.TempDir()
prev := calendarsDir
calendarsDir = dir
t.Cleanup(func() { calendarsDir = prev })
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
[]byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
t.Fatal(err)
}
writeCal(t, dir, "Gewicht",
`UID:1
SUMMARY:96 kg
DTSTART;VALUE=DATE:20261001`,
`UID:2
SUMMARY:95,6kg
DTSTART;VALUE=DATE:20261003`,
`UID:3
SUMMARY:not a weight
DTSTART;VALUE=DATE:20261004`,
`UID:4
SUMMARY:90 kg
DTSTART;VALUE=DATE:20261010`,
)
today := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
got := fitnessWeights(today)
want := []weightPoint{
{date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), value: 96},
{date: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), value: 95.6},
}
if len(got) != len(want) {
t.Fatalf("got %+v, want %+v", got, want)
}
for i := range want {
if !got[i].date.Equal(want[i].date) || got[i].value != want[i].value {
t.Fatalf("point %d: got %+v, want %+v", i, got[i], want[i])
}
}
}
func TestTimeXTicks(t *testing.T) {
d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) }
for _, c := range []struct {
from, to time.Time
want string
}{
{d(2022, 11, 28), d(2026, 9, 21), "2023 2024 2025 2026"},
{d(2025, 9, 27), d(2026, 9, 27), "Oct 2025|Jan 2026|Apr|Jul"},
{d(2026, 6, 27), d(2026, 9, 27), "Jul 2026|Aug|Sep"},
{d(2026, 8, 27), d(2026, 9, 27), "31 Aug|7 Sep|14 Sep|21 Sep"},
} {
var got []string
for _, tk := range timeXTicks(chartScale{x0: c.from, x1: c.to}) {
got = append(got, tk.Label)
}
sep := " "
if strings.Contains(c.want, "|") {
sep = "|"
}
if g := strings.Join(got, sep); g != c.want {
t.Errorf("%s..%s: got %q, want %q", c.from.Format("2006-01-02"), c.to.Format("2006-01-02"), g, c.want)
}
}
}
+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=
+91 -105
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/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.
@@ -74,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)
@@ -104,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)
@@ -156,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))
} }
@@ -175,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()))
@@ -197,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
@@ -230,6 +215,16 @@ 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) {
@@ -272,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.
@@ -301,8 +285,17 @@ 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) // An explicit ?canvas= selects this page's canvas view. Checked ahead of
// the pageTypeHandler redirect loop so an explicit view request always wins
// 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
} }
@@ -348,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) {
@@ -406,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")
@@ -419,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") {
@@ -458,10 +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("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)
@@ -573,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, "#") {
@@ -604,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
+42 -2
View File
@@ -58,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
@@ -129,6 +163,7 @@ var (
iconAudio = readIcon("audio") iconAudio = readIcon("audio")
iconArchive = readIcon("archive") iconArchive = readIcon("archive")
iconGeneric = readIcon("generic") iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
) )
// renderMarkdown converts raw markdown to trusted HTML. // renderMarkdown converts raw markdown to trusted HTML.
@@ -210,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,
@@ -299,6 +337,8 @@ 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 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
+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 {
+1 -1
View File
@@ -91,7 +91,7 @@ func secionSpanEnd(sections [][]byte, start int) int {
if level == 0 { if level == 0 {
return start + 1 return start + 1
} }
for i := start + 1; i <= len(sections); i++ { for i := start + 1; i < len(sections); i++ {
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level { if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
return i return i
} }
-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=""`)
} }
+4 -41
View File
@@ -12,7 +12,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -49,13 +48,8 @@ var (
thumbLocks = map[string]*sync.Mutex{} thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{} digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex digestCacheMu sync.Mutex
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
// photos otherwise decodes one full-size image per browser connection at
// once, which on the NAS means memory pressure and CPU thrash.
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
) )
// digestEntry remembers the digest of a source file so repeated requests do // digestEntry remembers the digest of a source file so repeated requests do
@@ -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) {
+16 -43
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()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
} }
} }
return dst return dst
} }
// boxAvg returns the mean colour of the pixels of src inside r.
func boxAvg(src image.Image, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
sr, sg, sb, _ := src.At(x, y).RGBA()
red += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly.
// Each pixel is still converted to RGB before averaging so out-of-gamut
// samples clamp exactly as they do through At().
func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA {
var red, g, bl, n uint64
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
ci := src.COffset(x, y)
sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci])
red += uint64(sr)
g += uint64(sg)
bl += uint64(sb)
n++
}
}
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
}
-89
View File
@@ -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)
}
}