Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48d476b39 |
@@ -4,10 +4,6 @@ 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
|
||||||
|
|
||||||
@@ -18,10 +14,10 @@ EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-bu
|
|||||||
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,7 +37,7 @@ $(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
|
||||||
|
|||||||
@@ -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 ``.
|
|
||||||
- **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 — `` — 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)
|
||||||
|
|||||||
@@ -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 = <name></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}}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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}}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -24,4 +24,4 @@
|
|||||||
</table>
|
</table>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<script src="{{asset "diary/calendar.js"}}"></script>
|
<script src="/_/diary/calendar.js"></script>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +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.
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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="M8 3v10M3 8h10"/>
|
<path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 249 B |
@@ -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 |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 241 B |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -4,19 +4,19 @@
|
|||||||
<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 "modal.js"}}"></script>
|
<script src="/_/modal.js"></script>
|
||||||
<script src="{{asset "global-shortcuts.js"}}"></script>
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
<script src="{{asset "history-nav.js"}}"></script>
|
<script src="/_/history-nav.js"></script>
|
||||||
<script src="{{asset "scroll-sync.js"}}" defer></script>
|
<script src="/_/scroll-sync.js" defer></script>
|
||||||
<script src="{{asset "search-suggest.js"}}" defer></script>
|
<script src="/_/search-suggest.js" defer></script>
|
||||||
<script src="{{asset "tree-picker.js"}}"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="{{asset "companion.js"}}" defer></script>
|
<script src="/_/companion.js" defer></script>
|
||||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
{{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
|
||||||
<script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}}
|
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -24,14 +24,13 @@
|
|||||||
<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">
|
||||||
@@ -43,23 +42,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
|
||||||
<div class="tree-scroll"></div>
|
<div class="tree-scroll"></div>
|
||||||
<script>
|
|
||||||
// Paint the previous page's tree before first render so the rail
|
|
||||||
// doesn't flash empty; tree-sidebar.js swaps in the live tree.
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var s = JSON.parse(sessionStorage.getItem("tree-snapshot"));
|
|
||||||
if (!s) return;
|
|
||||||
var el = document.currentScript.previousElementSibling;
|
|
||||||
el.innerHTML = s.html;
|
|
||||||
var a = el.querySelector('a.tree-name[href="' + CSS.escape(location.pathname.replace(/\/*$/, "/")) + '"]');
|
|
||||||
if (a) a.parentNode.classList.add("is-active");
|
|
||||||
el.scrollTop = s.top;
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</aside>{{end}}
|
</aside>{{end}}
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<main>
|
<main>
|
||||||
@@ -74,7 +58,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
|
{{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" onclick="deletePage()" 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" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
||||||
{{if eq .View "thumbnail"}}
|
{{if eq .View "thumbnail"}}
|
||||||
@@ -44,17 +53,21 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{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="{{asset "page/tasks.js"}}"></script>
|
<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}}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
|
||||||
|
|
||||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||||
|
|
||||||
@@ -43,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"}}
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -411,10 +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;}
|
||||||
/* 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
|
||||||
@@ -823,46 +808,13 @@ aside.sidebar:empty { display: none; }
|
|||||||
.diary-cal-grid td.cal-current,
|
.diary-cal-grid td.cal-current,
|
||||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||||
.btn-block.cal-current { color: var(--primary-hover); }
|
.btn-block.cal-current { color: var(--primary-hover); }
|
||||||
/* Calendar events and weight under a diary day heading. */
|
|
||||||
.diary-day-cal { margin: var(--space-3) 0 0; }
|
|
||||||
.diary-day-cal p { margin: 0; }
|
|
||||||
|
|
||||||
/* === Calendars ===
|
|
||||||
Header maintenance chip + dropdown, and the calendars folder page. */
|
|
||||||
.maint-toggle { display: inline-flex; align-items: center; }
|
|
||||||
.maint-count { margin-left: 0.3rem; }
|
|
||||||
.maint-toggle.is-idle { color: var(--text-muted); }
|
|
||||||
.maint-menu {
|
|
||||||
min-width: 20rem;
|
|
||||||
max-width: calc(100vw - 2rem);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
|
|
||||||
.maint-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content 1fr;
|
|
||||||
column-gap: var(--space-2);
|
|
||||||
row-gap: 0.15rem;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
.maint-list > .muted { grid-column: 1 / -1; }
|
|
||||||
.maint-list + .panel-header { margin-top: var(--space-3); }
|
|
||||||
.maint-when { color: var(--text-muted); white-space: nowrap; }
|
|
||||||
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
|
|
||||||
.cal-scroll { overflow-x: auto; }
|
|
||||||
.cal-table .nowrap { white-space: nowrap; }
|
|
||||||
.cal-error { color: var(--danger); font-size: var(--font-sm); }
|
|
||||||
.cal-sync { margin-top: var(--space-3); }
|
|
||||||
|
|
||||||
/* === Fitness dashboard ===
|
/* === Fitness dashboard ===
|
||||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||||
colors and strokes are applied here via classes so the inline SVG follows
|
colors and strokes are applied here via classes so the inline SVG follows
|
||||||
the theme palette. */
|
the theme palette. */
|
||||||
.fitness-chart { 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);
|
||||||
@@ -876,24 +828,8 @@ aside.sidebar:empty { display: none; }
|
|||||||
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||||
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||||
.fitness-svg .chart-dot { fill: var(--link); }
|
.fitness-svg .chart-dot { fill: var(--link); }
|
||||||
.fitness-svg.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) {
|
||||||
@@ -915,9 +851,7 @@ aside.sidebar:empty { display: none; }
|
|||||||
@media (min-width: 1101px) {
|
@media (min-width: 1101px) {
|
||||||
.actions-toggle, .actions-special {display: none;}
|
.actions-toggle, .actions-special {display: none;}
|
||||||
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
|
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
|
||||||
.actions-menu .btn-block { width: auto; padding: 0; 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) {
|
||||||
@@ -966,3 +900,319 @@ aside.sidebar:empty { display: none; }
|
|||||||
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
|
.list-item td.meta { grid-row: 2; grid-column: 2; text-align: left; }
|
||||||
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
|
.toc {float: none; width: auto; max-width: none; margin: 0 0 var(--space-4);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Canvas ===
|
||||||
|
A canvas is an alternative view of a page (see canvas.go). The tab strip is
|
||||||
|
shared with the normal page template; everything below it only exists in
|
||||||
|
canvas mode. */
|
||||||
|
|
||||||
|
/* --- view switcher (rendered on normal pages too) --- */
|
||||||
|
.canvas-tabs {
|
||||||
|
gap: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: var(--border-dashed);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.canvas-tab {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
/* Overlap the strip's own border so the active tab joins it. */
|
||||||
|
margin-bottom: -1px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.canvas-tab:hover { color: var(--link-hover); }
|
||||||
|
.canvas-tab.is-active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--secondary);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.canvas-tab-add { color: var(--secondary); }
|
||||||
|
|
||||||
|
/* --- full-bleed view mode ---
|
||||||
|
The canvas owns the viewport instead of the centred reading column: main
|
||||||
|
loses its max-width and padding, and the centre column stops scrolling so the
|
||||||
|
surface can handle pan itself. */
|
||||||
|
body.canvas-mode .center { overflow: hidden; }
|
||||||
|
body.canvas-mode main {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
body.canvas-mode footer { max-width: none; }
|
||||||
|
body.canvas-mode .canvas-tabs { margin: 0; padding: var(--space-2) var(--space-4) 0; }
|
||||||
|
|
||||||
|
.canvas-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
border-top: var(--border);
|
||||||
|
border-bottom: var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.canvas-status { margin-left: auto; padding-left: var(--space-3); }
|
||||||
|
.canvas-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.7em;
|
||||||
|
height: 0.7em;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
border: 1px solid var(--bg);
|
||||||
|
}
|
||||||
|
.canvas-swatch[data-swatch="1"] { background: var(--canvas-1); }
|
||||||
|
.canvas-swatch[data-swatch="2"] { background: var(--canvas-2); }
|
||||||
|
.canvas-swatch[data-swatch="3"] { background: var(--canvas-3); }
|
||||||
|
.canvas-swatch[data-swatch="4"] { background: var(--canvas-4); }
|
||||||
|
.canvas-swatch[data-swatch="5"] { background: var(--canvas-5); }
|
||||||
|
.canvas-swatch[data-swatch="6"] { background: var(--canvas-6); }
|
||||||
|
|
||||||
|
/* --- surface, world, layers ---
|
||||||
|
touch-action:none hands every touch gesture to the pointer handlers; without
|
||||||
|
it the browser claims one-finger drags for scrolling and two-finger for page
|
||||||
|
zoom, and neither reaches the canvas. */
|
||||||
|
.canvas-surface {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: grab;
|
||||||
|
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1px);
|
||||||
|
background-repeat: repeat;
|
||||||
|
}
|
||||||
|
.canvas-surface.is-grid-hidden { background-image: none; }
|
||||||
|
|
||||||
|
/* The world is a zero-size anchor: all of its content is absolutely positioned
|
||||||
|
in world coordinates and the whole thing is moved by one transform, so pan
|
||||||
|
and zoom never touch a single node's layout. */
|
||||||
|
.canvas-world {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.canvas-layer { position: absolute; top: 0; left: 0; }
|
||||||
|
/* Drawn in world coordinates with no viewport of its own; overflow:visible is
|
||||||
|
what lets it paint outside its 1x1 box, including at negative coordinates. */
|
||||||
|
.canvas-edges {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- nodes --- */
|
||||||
|
.canvas-node {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--node-accent, var(--secondary));
|
||||||
|
background: var(--bg-panel);
|
||||||
|
/* Visible so the connection ports and resize handles, which sit on the
|
||||||
|
border, are not clipped; the body clips its own content instead. */
|
||||||
|
overflow: visible;
|
||||||
|
contain: layout paint;
|
||||||
|
}
|
||||||
|
.canvas-node[data-color="1"] { --node-accent: var(--canvas-1); }
|
||||||
|
.canvas-node[data-color="2"] { --node-accent: var(--canvas-2); }
|
||||||
|
.canvas-node[data-color="3"] { --node-accent: var(--canvas-3); }
|
||||||
|
.canvas-node[data-color="4"] { --node-accent: var(--canvas-4); }
|
||||||
|
.canvas-node[data-color="5"] { --node-accent: var(--canvas-5); }
|
||||||
|
.canvas-node[data-color="6"] { --node-accent: var(--canvas-6); }
|
||||||
|
.canvas-node.is-selected { outline: 2px solid var(--primary-hover); outline-offset: 1px; }
|
||||||
|
.canvas-node.is-edge-target { outline: 2px dashed var(--link); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.canvas-node-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.canvas-node-body.content { margin-bottom: 0; }
|
||||||
|
.canvas-node-body > :first-child { margin-top: 0; }
|
||||||
|
.canvas-node-body > :last-child { margin-bottom: 0; }
|
||||||
|
.canvas-node-body h1 { font-size: 1.1rem; }
|
||||||
|
.canvas-node-body h2 { font-size: 1rem; }
|
||||||
|
.canvas-node-body h3 { font-size: 0.95rem; }
|
||||||
|
/* Task checkboxes are rendered enabled for pages, where page/tasks.js writes
|
||||||
|
the toggle back to index.md. Inside a canvas node there is no such wiring and
|
||||||
|
the markdown lives in the .canvas file, so a click would be a lie. */
|
||||||
|
.canvas-node-body .task-checkbox { pointer-events: none; }
|
||||||
|
|
||||||
|
.canvas-node-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: var(--border-dashed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A transcluded image or player fills its node edge to edge. */
|
||||||
|
.canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; }
|
||||||
|
.canvas-media { display: block; width: 100%; height: 100%; }
|
||||||
|
.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.canvas-broken { color: var(--danger-hover); }
|
||||||
|
.canvas-file-fallback { padding: var(--space-2) 0; }
|
||||||
|
|
||||||
|
.canvas-link-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: flex-start;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.canvas-link-host { color: var(--link); }
|
||||||
|
.canvas-link-url { font-size: var(--font-xs); color: var(--text-muted); max-width: 100%; }
|
||||||
|
.link-favicon { vertical-align: middle; margin-right: 3px; }
|
||||||
|
|
||||||
|
.canvas-node-group {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px dashed var(--node-accent, var(--bg-panel-hover));
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
.canvas-node-group[data-bg-style="cover"] { background-size: cover; }
|
||||||
|
.canvas-node-group[data-bg-style="ratio"] { background-size: contain; background-repeat: no-repeat; }
|
||||||
|
.canvas-node-group[data-bg-style="repeat"] { background-repeat: repeat; }
|
||||||
|
.canvas-node-group .canvas-node-body { display: none; }
|
||||||
|
.canvas-group-label {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ports and handles ---
|
||||||
|
Both are positioned on the node border (the negative margin centres them on
|
||||||
|
it) and stay invisible until the node is hovered or selected, so a dense
|
||||||
|
canvas is not covered in dots. */
|
||||||
|
.canvas-port {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.canvas-node:hover .canvas-port,
|
||||||
|
.canvas-node.is-selected .canvas-port { opacity: 1; }
|
||||||
|
.canvas-port:hover { background: var(--primary-hover); }
|
||||||
|
.canvas-port-top { top: 0; left: 50%; }
|
||||||
|
.canvas-port-bottom { top: 100%; left: 50%; }
|
||||||
|
.canvas-port-left { top: 50%; left: 0; }
|
||||||
|
.canvas-port-right { top: 50%; left: 100%; }
|
||||||
|
|
||||||
|
.canvas-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin: -5px;
|
||||||
|
background: var(--primary-hover);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.canvas-node.is-selected .canvas-handle { opacity: 1; }
|
||||||
|
.canvas-handle-nw { top: 0; left: 0; cursor: nwse-resize; }
|
||||||
|
.canvas-handle-ne { top: 0; left: 100%; cursor: nesw-resize; }
|
||||||
|
.canvas-handle-se { top: 100%; left: 100%; cursor: nwse-resize; }
|
||||||
|
.canvas-handle-sw { top: 100%; left: 0; cursor: nesw-resize; }
|
||||||
|
|
||||||
|
/* --- edges ---
|
||||||
|
The hit path is a fat transparent stroke under the visible curve: a 2px
|
||||||
|
bezier is effectively unclickable without it. */
|
||||||
|
.canvas-edge-hit {
|
||||||
|
stroke: transparent;
|
||||||
|
stroke-width: 16;
|
||||||
|
pointer-events: stroke;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.canvas-edge-line { stroke-width: 2; pointer-events: none; }
|
||||||
|
.canvas-edge-head { pointer-events: none; }
|
||||||
|
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); stroke-width: 3; }
|
||||||
|
.canvas-edge.is-selected .canvas-edge-head { fill: var(--primary-hover); }
|
||||||
|
.canvas-edge-label {
|
||||||
|
font-family: "Iosevka Etoile", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
fill: var(--text);
|
||||||
|
/* A stroke painted under the fill gives the label a halo against whatever
|
||||||
|
it overlaps, without measuring a background rect. */
|
||||||
|
stroke: var(--bg);
|
||||||
|
stroke-width: 4;
|
||||||
|
paint-order: stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.canvas-edge-temp {
|
||||||
|
stroke: var(--primary-hover);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- overlays --- */
|
||||||
|
.canvas-marquee {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px dashed var(--primary-hover);
|
||||||
|
background: rgba(214, 77, 149, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.canvas-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: 30rem;
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The node editor is fixed to the viewport rather than mounted in the zoomed
|
||||||
|
world: CodeMirror measures itself in device pixels and a scaled ancestor
|
||||||
|
breaks its caret and selection maths. */
|
||||||
|
.canvas-editor {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.canvas-editor .panel-header { padding: var(--space-2) var(--space-3); margin-bottom: 0; }
|
||||||
|
.canvas-editor-mount { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||||
|
.canvas-editor-mount .cm-editor { height: 100%; }
|
||||||
|
/* The shared editor theme sets a 60vh floor for the full-page editor; in a
|
||||||
|
node-sized panel that would force a scrollbar at every size. The theme is
|
||||||
|
injected at runtime and lands after this sheet, so it needs overriding. */
|
||||||
|
.canvas-editor-mount .cm-scroller { min-height: 0 !important; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.canvas-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
||||||
|
.canvas-toolbar .btn-tool { padding: 0.2rem 0.4rem; }
|
||||||
|
body.canvas-mode .canvas-tabs { padding: var(--space-2) var(--space-2) 0; }
|
||||||
|
/* Touch has no hover, so ports would never appear; show them on selection
|
||||||
|
only and make both targets finger-sized. */
|
||||||
|
.canvas-port { width: 16px; height: 16px; margin: -8px; }
|
||||||
|
.canvas-handle { width: 14px; height: 14px; margin: -7px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
var activeRow = null
|
var activeRow = null
|
||||||
var live = false // set once the fetched tree is on screen
|
|
||||||
|
|
||||||
function disabledRow(text) {
|
function disabledRow(text) {
|
||||||
var row = document.createElement("div")
|
var row = document.createElement("div")
|
||||||
@@ -85,8 +84,7 @@
|
|||||||
var childrenEl = null
|
var childrenEl = null
|
||||||
var loaded = false
|
var loaded = false
|
||||||
var isOpen = false
|
var isOpen = false
|
||||||
var isLeaf = !entry.hasChildren
|
var isLeaf = false
|
||||||
if (isLeaf) chevron.classList.add("is-leaf")
|
|
||||||
|
|
||||||
function ensureChildrenEl() {
|
function ensureChildrenEl() {
|
||||||
if (!childrenEl) {
|
if (!childrenEl) {
|
||||||
@@ -170,26 +168,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
// If the layout pre-painted a snapshot of the previous page's tree, keep it
|
container.textContent = ""
|
||||||
// on screen and build the live tree off-DOM, swapping it in once ready —
|
|
||||||
// no "…" placeholder, no scroll jump.
|
|
||||||
var listing = document.createElement("div")
|
var listing = document.createElement("div")
|
||||||
var hasSnapshot = container.firstChild !== null
|
container.appendChild(listing)
|
||||||
if (!hasSnapshot) {
|
listing.appendChild(disabledRow("…"))
|
||||||
container.appendChild(listing)
|
|
||||||
listing.appendChild(disabledRow("…"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchFolder("/", activePath === "/" ? "" : activePath)
|
fetchFolder("/", activePath === "/" ? "" : activePath)
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
listing.textContent = ""
|
listing.textContent = ""
|
||||||
renderInto(listing, "/", resp.entries)
|
renderInto(listing, "/", resp.entries)
|
||||||
if (hasSnapshot) {
|
|
||||||
var top = container.scrollTop
|
|
||||||
container.replaceChildren(listing)
|
|
||||||
container.scrollTop = top
|
|
||||||
}
|
|
||||||
live = true
|
|
||||||
if (activeRow) {
|
if (activeRow) {
|
||||||
try {
|
try {
|
||||||
activeRow.scrollIntoView({ block: "nearest" })
|
activeRow.scrollIntoView({ block: "nearest" })
|
||||||
@@ -197,34 +184,17 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
live = false
|
|
||||||
container.replaceChildren(listing)
|
|
||||||
listing.textContent = ""
|
listing.textContent = ""
|
||||||
var err = document.createElement("div")
|
var err = document.createElement("div")
|
||||||
err.className = "tree-row"
|
err.className = "tree-row"
|
||||||
err.textContent = "(failed — tap to retry)"
|
err.textContent = "(failed — tap to retry)"
|
||||||
err.addEventListener("click", function () {
|
err.addEventListener("click", function () {
|
||||||
container.textContent = ""
|
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
listing.appendChild(err)
|
listing.appendChild(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the tree (markup + scroll) on the way out so the next page's
|
|
||||||
// layout can paint it immediately. Skipped until the live tree has rendered,
|
|
||||||
// so a placeholder or error row never becomes the snapshot. The active
|
|
||||||
// highlight is left out; the next page marks its own row.
|
|
||||||
window.addEventListener("pagehide", function () {
|
|
||||||
if (!live) return
|
|
||||||
if (activeRow) activeRow.classList.remove("is-active")
|
|
||||||
var html = container.innerHTML
|
|
||||||
if (activeRow) activeRow.classList.add("is-active") // bfcache may restore this page
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem("tree-snapshot", JSON.stringify({ html: html, top: container.scrollTop }))
|
|
||||||
} catch (e) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
function setupMenuItem() {
|
function setupMenuItem() {
|
||||||
var specials = document.querySelector(".actions-special")
|
var specials = document.querySelector(".actions-special")
|
||||||
if (!specials) return
|
if (!specials) return
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 := §ions[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.
|
||||||
|
|||||||
@@ -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>`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -302,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
for _, ph := range pageTypeHandlers {
|
for _, ph := range pageTypeHandlers {
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
@@ -344,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) {
|
||||||
@@ -407,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
|
Canvases: canvases,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -414,7 +420,7 @@ 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, "layout", data); err != nil {
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -422,6 +428,12 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
|
|
||||||
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
|
||||||
@@ -442,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)
|
||||||
@@ -557,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, "#") {
|
||||||
@@ -588,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -63,7 +63,40 @@ type pageData struct {
|
|||||||
SpecialContent template.HTML
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
renderTimer
|
RenderMS int64
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -130,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.
|
||||||
@@ -211,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,
|
||||||
@@ -300,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":
|
||||||
|
|||||||
@@ -12,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
|
||||||
@@ -47,9 +46,14 @@ type searchPageData struct {
|
|||||||
PageTotal int
|
PageTotal int
|
||||||
FileTotal int
|
FileTotal int
|
||||||
IndexBuiltAt time.Time
|
IndexBuiltAt time.Time
|
||||||
renderTimer
|
RenderMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,7 +137,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
IndexBuiltAt: builtAt,
|
IndexBuiltAt: builtAt,
|
||||||
}
|
}
|
||||||
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, "layout", 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)
|
||||||
}
|
}
|
||||||
@@ -279,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
|
||||||
}
|
}
|
||||||
@@ -576,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,6 @@ import (
|
|||||||
type treeEntry struct {
|
type treeEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
// HasChildren reports whether a folder has any visible entries (under the
|
|
||||||
// same foldersOnly filter), so the client can hide the expand chevron on
|
|
||||||
// empty folders without a round-trip. Always false for files.
|
|
||||||
HasChildren bool `json:"hasChildren"`
|
|
||||||
// Children is populated only along the expandTo chain (see handleTree);
|
// Children is populated only along the expandTo chain (see handleTree);
|
||||||
// omitted otherwise so the flat picker listing keeps its original shape.
|
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||||
Children []treeEntry `json:"children,omitempty"`
|
Children []treeEntry `json:"children,omitempty"`
|
||||||
@@ -85,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
|
||||||
@@ -102,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
|
||||||
|
|||||||